Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ef6868f00 | ||
|
|
819c360a6c | ||
|
|
1e848019d9 | ||
|
|
be00bc6f65 |
@@ -61,26 +61,3 @@ harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
|
||||
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
|
||||
harness/release-native-child-continuation-review-20260911.md -text -whitespace
|
||||
|
||||
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
|
||||
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
|
||||
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
|
||||
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
|
||||
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
|
||||
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
|
||||
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
|
||||
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
|
||||
|
||||
@@ -2176,7 +2176,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -18,7 +18,6 @@ serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tempfile = "3"
|
||||
unicode-segmentation = "1.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
|
||||
@@ -5,7 +5,6 @@ mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
@@ -58,7 +57,7 @@ mod direct_scenario_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() {
|
||||
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
@@ -74,26 +73,18 @@ mod direct_scenario_tests {
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)),
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(context));
|
||||
let context = app.scenario_context().unwrap();
|
||||
for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] {
|
||||
for (area, offset) in [
|
||||
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||||
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||||
] {
|
||||
let before = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
offset,
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(before));
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
offset,
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
@@ -122,7 +113,7 @@ mod direct_scenario_tests {
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
scenario::ScrollArea::Page,
|
||||
scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
@@ -327,7 +318,6 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
BehaviorControl(behavior::canvas_workspace::Intent),
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
CheckpointTick,
|
||||
@@ -381,8 +371,6 @@ pub enum Message {
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldReferenceBrowse(String, String, usize),
|
||||
FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
FieldReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -457,14 +445,10 @@ pub struct App {
|
||||
pub hosted: crate::hosted::State,
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
reference_authoring: reference_authoring::State,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
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,
|
||||
@@ -563,13 +547,10 @@ impl App {
|
||||
checkpoint_close: None,
|
||||
connection: None,
|
||||
model_menu: None,
|
||||
reference_authoring: Default::default(),
|
||||
source_tree: Default::default(),
|
||||
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,
|
||||
@@ -1046,13 +1027,7 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::behavior::scenario::focus::root(
|
||||
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(),
|
||||
)
|
||||
crate::hosted::view::overlay(self, base)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1108,108 +1083,6 @@ 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;
|
||||
}
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
@@ -1432,7 +1305,6 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
@@ -1500,23 +1372,8 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
|
||||
if self.busy
|
||||
|| self.hosted.visible
|
||||
|| self.workflow.is_some()
|
||||
|| self.architecture.is_some()
|
||||
|| self.model_menu.is_some()
|
||||
|| self.file_prompt.is_some()
|
||||
|| self.delete_pending
|
||||
|| self.pending.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
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(),
|
||||
@@ -1532,18 +1389,6 @@ impl App {
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::FormShown(_, mount) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.scenario_focus_owner().is_some()
|
||||
&& self.behavior.scenario.take_form_focus(mount)
|
||||
{
|
||||
return scenario::focus::task(self.scenario_focus_owner().unwrap());
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
@@ -1568,9 +1413,9 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
// Details may report more than once in a frame. Accept the
|
||||
// current owner's passive clamps across an epoch bump while
|
||||
// still rejecting another document, owner or revision.
|
||||
// Canvas and outline notifications may come from the
|
||||
// same frame. Accept both across the first epoch bump,
|
||||
// while still rejecting another owner/document/revision.
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
@@ -1581,49 +1426,6 @@ 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();
|
||||
}
|
||||
@@ -1637,11 +1439,9 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, scale) {
|
||||
if self.behavior.scenario.zoom(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()
|
||||
@@ -1725,6 +1525,37 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::First(_, tool) => {
|
||||
let empty = match tool {
|
||||
scenario::Tool::Lifeline => !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline),
|
||||
scenario::Tool::Message | scenario::Tool::Action => {
|
||||
self.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline)
|
||||
&& !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Occurrence)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !empty {
|
||||
self.status="This scenario already has content. Use its current authoring tools.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
CanvasEvent::BeginOccurrence {
|
||||
tool,
|
||||
slot,
|
||||
@@ -1732,22 +1563,13 @@ impl App {
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
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,
|
||||
)
|
||||
};
|
||||
let result = state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -2040,9 +1862,6 @@ impl App {
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => {
|
||||
return Task::none();
|
||||
}
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
let page = if draft
|
||||
@@ -2283,7 +2102,7 @@ impl App {
|
||||
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
|
||||
match event {
|
||||
crate::workflow::Event::Semantic(event) => {
|
||||
state.update(self.project(), *event)
|
||||
state.update(self.project(), event)
|
||||
}
|
||||
crate::workflow::Event::Apply => {
|
||||
let focus = state.target(self.project());
|
||||
@@ -3037,17 +2856,11 @@ impl App {
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
}
|
||||
Err(error) => {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.document.invalidate_saved_baseline();
|
||||
}
|
||||
self.status = format!(
|
||||
"Disk recovery completed; files could not be reloaded: {error}"
|
||||
)
|
||||
@@ -3864,7 +3677,6 @@ impl App {
|
||||
self.canvas_document = Id::new_v4();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.show_source_file(false);
|
||||
@@ -4532,10 +4344,6 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
#[path = "app/save_as_tests.rs"]
|
||||
mod save_as_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app/repair_save_state_tests.rs"]
|
||||
mod repair_save_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_project_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -15,15 +15,18 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
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 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 edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
@@ -49,47 +52,6 @@ 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();
|
||||
}
|
||||
@@ -119,7 +81,6 @@ impl App {
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
@@ -157,18 +118,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before choosing a process tool.".into();
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::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.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
@@ -192,9 +149,7 @@ impl App {
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior
|
||||
.topology
|
||||
.remember(context.owner, occurrence, target);
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
@@ -569,30 +524,32 @@ mod tests {
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
)));
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
assert_eq!(
|
||||
app.behavior.topology.details_offset(),
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
@@ -600,12 +557,12 @@ mod tests {
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.details_offset(),
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
@@ -625,7 +582,6 @@ mod tests {
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
//! One transient chooser for the currently mounted Inspector or review field.
|
||||
//! Browsing never calls a draft setter or semantic planner.
|
||||
use super::{App, Message, Page, Pending};
|
||||
use crate::{
|
||||
forms::{
|
||||
Draft,
|
||||
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
|
||||
},
|
||||
navigation::InspectorTab,
|
||||
semantic_review::{self, RowAddress, RowEvent},
|
||||
workflow,
|
||||
};
|
||||
use iced::{Element, Rectangle, Task};
|
||||
use oa_core::{FacetSelector, Id, Layer};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Host {
|
||||
Inspector,
|
||||
Review {
|
||||
address: Option<RowAddress>,
|
||||
facet: FacetSelector,
|
||||
},
|
||||
}
|
||||
impl Host {
|
||||
fn message(&self, event: chooser::Event) -> Message {
|
||||
match self {
|
||||
Self::Inspector => Message::FieldReferenceEvent(event),
|
||||
Self::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceEvent(event),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Opened {
|
||||
host: Host,
|
||||
request: adapter::Open,
|
||||
document_session: Id,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
chooser: chooser::State,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
opened: Option<Opened>,
|
||||
}
|
||||
|
||||
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
|
||||
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
|
||||
{
|
||||
self.draft.as_ref()
|
||||
}
|
||||
Host::Review { address, facet } => match self.workflow.as_ref()? {
|
||||
workflow::Workflow::Semantic(state) => {
|
||||
state.reference_draft(self.project(), *address, facet, path)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn reference_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.architecture.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.pending.is_none()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
|
||||
}
|
||||
fn reference_valid(&self, opened: &Opened) -> bool {
|
||||
self.reference_available()
|
||||
&& self.canvas_document == opened.document_session
|
||||
&& self.page == opened.page
|
||||
&& self.layer == opened.layer
|
||||
&& self.document.revision() == opened.chooser.stamp().context.revision
|
||||
&& self.project().id == opened.request.document
|
||||
&& self
|
||||
.reference_draft(&opened.host, &opened.request.path)
|
||||
.is_some_and(|draft| draft.reference_matches(&opened.request))
|
||||
}
|
||||
pub(super) fn reconcile_reference_chooser(&mut self) {
|
||||
if self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.is_some_and(|opened| !self.reference_valid(opened))
|
||||
{
|
||||
self.reference_authoring
|
||||
.opened
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.invalidate();
|
||||
self.reference_authoring.opened = None;
|
||||
}
|
||||
}
|
||||
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
|
||||
if !self.reference_available() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(draft) = self.reference_draft(&host, &request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(chooser) =
|
||||
draft.reference_catalog(self.project(), &request, self.document.revision())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.reference_authoring.opened = Some(Opened {
|
||||
host,
|
||||
request,
|
||||
document_session: self.canvas_document,
|
||||
page: self.page,
|
||||
layer: self.layer,
|
||||
chooser,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
|
||||
let Some(mut opened) = self.reference_authoring.opened.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.reference_valid(&opened) {
|
||||
return Task::none();
|
||||
}
|
||||
if opened.host != host {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
return Task::none();
|
||||
}
|
||||
match opened.chooser.update(event) {
|
||||
Outcome::Ignored | Outcome::Browsed => {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
Task::none()
|
||||
}
|
||||
Outcome::Closed(reason) => self.reference_focus(
|
||||
&opened,
|
||||
match reason {
|
||||
Close::Escape => Some(0),
|
||||
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
|
||||
Close::Outside | Close::WindowChanged => None,
|
||||
},
|
||||
),
|
||||
Outcome::Write { context, value } => {
|
||||
let intent = adapter::Set {
|
||||
context,
|
||||
generation: opened.request.generation,
|
||||
value,
|
||||
};
|
||||
let result = match &opened.host {
|
||||
Host::Inspector => self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or_else(|| "The property editor closed.".to_owned())
|
||||
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
|
||||
Host::Review { address, facet } => match self.workflow.as_mut() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
state.set_reference(self.document.project(), *address, facet, intent)
|
||||
}
|
||||
_ => Err("The review editor closed.".into()),
|
||||
},
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
// A scoped write rotates field generation. The task targets the
|
||||
// newly rendered field, while a later structural edit makes it inert.
|
||||
self.reference_focus(&opened, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
|
||||
let Some(step) = step else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
if draft.reference_session() != opened.request.session {
|
||||
return Task::none();
|
||||
}
|
||||
trigger::focus(
|
||||
draft.reference_session(),
|
||||
draft.reference_generation(),
|
||||
&opened.request.path,
|
||||
step,
|
||||
)
|
||||
}
|
||||
/// Called before generic modal dispatch: the review owns a modal, and this
|
||||
/// chooser owns only its active field. All browser events stop here.
|
||||
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
|
||||
self.reconcile_reference_chooser();
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Some(self.open_reference(Host::Inspector, request.clone()))
|
||||
}
|
||||
Message::FieldReferenceEvent(event) => {
|
||||
Some(self.reference_event(Host::Inspector, event.clone()))
|
||||
}
|
||||
Message::Workflow(workflow::Event::Semantic(review_event)) => {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event,
|
||||
} = review_event.as_ref()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let host = Host::Review {
|
||||
address: *address,
|
||||
facet: facet.clone(),
|
||||
};
|
||||
match event {
|
||||
RowEvent::ReferenceOpen(request) => {
|
||||
Some(self.open_reference(host, request.clone()))
|
||||
}
|
||||
RowEvent::ReferenceEvent(event) => {
|
||||
Some(self.reference_event(host, event.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
let opened = self.reference_authoring.opened.as_ref().unwrap();
|
||||
let host = opened.host.clone();
|
||||
let event = opened.chooser.event(Action::Close(Close::Escape));
|
||||
Some(self.reference_event(host, event))
|
||||
}
|
||||
// Global shortcuts are also emitted for keys captured by the search
|
||||
// input. They must not apply drafts, navigate, or undo the model.
|
||||
Message::Save(_)
|
||||
| Message::ShortcutUndo(_)
|
||||
| Message::Request(Pending::Open | Pending::New)
|
||||
| Message::SourceNavigation(_)
|
||||
| Message::Page(Page::Guide)
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn reference_overlay<
|
||||
'a,
|
||||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
|
||||
>(
|
||||
&'a self,
|
||||
content: Element<'a, Message, iced::Theme, R>,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let opened = self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.filter(|opened| self.reference_valid(opened));
|
||||
let host = opened
|
||||
.map(|opened| opened.host.clone())
|
||||
.unwrap_or(Host::Inspector);
|
||||
let focus_scope = if self.reference_available() {
|
||||
match self.workflow.as_ref() {
|
||||
None if self.inspector_tab == InspectorTab::Properties => {
|
||||
self.draft.as_ref().map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: false,
|
||||
})
|
||||
}
|
||||
Some(workflow::Workflow::Semantic(state)) => state
|
||||
.reference_form(self.project())
|
||||
.map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
chooser::widget::Popup::new(
|
||||
content,
|
||||
opened.map(|opened| &opened.chooser),
|
||||
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
|
||||
move |event| host.message(event),
|
||||
)
|
||||
.focus_scope(focus_scope)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,586 +0,0 @@
|
||||
use super::*;
|
||||
use crate::forms::Draft;
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const OWNER: Id = Id::from_u128(100);
|
||||
const BASE: u128 = 10_000;
|
||||
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
|
||||
|
||||
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
|
||||
let generated = p.add_element(kind, layer, name, parent).unwrap();
|
||||
let mut element = p.elements.remove(&generated).unwrap();
|
||||
element.id = id;
|
||||
element.alias = format!("object_{}", id.as_u128());
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.elements.insert(id, element);
|
||||
}
|
||||
fn catalog() -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
p.name = "Reference catalog".into();
|
||||
element(
|
||||
&mut p,
|
||||
OWNER,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Edited record",
|
||||
None,
|
||||
);
|
||||
for n in (0..1_000).rev() {
|
||||
let id = Id::from_u128(BASE + n);
|
||||
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
|
||||
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(200),
|
||||
Kind::DataType,
|
||||
Layer::Sa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(201),
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
p
|
||||
}
|
||||
fn inspector(p: Project, selected: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(selected));
|
||||
app
|
||||
}
|
||||
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
|
||||
app.reference_draft(host, path).unwrap().reference_open(
|
||||
app.project(),
|
||||
path,
|
||||
Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn open(app: &mut App, host: &Host, path: &str) {
|
||||
let request = request(app, host, path);
|
||||
let message = match host {
|
||||
Host::Inspector => Message::FieldReferenceOpen(request),
|
||||
Host::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceOpen(request),
|
||||
}))
|
||||
}
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert!(
|
||||
app.reference_authoring.opened.is_some(),
|
||||
"field must open: {}",
|
||||
app.status
|
||||
);
|
||||
}
|
||||
fn event(app: &App, action: Action) -> Message {
|
||||
let opened = app.reference_authoring.opened.as_ref().unwrap();
|
||||
opened.host.message(opened.chooser.event(action))
|
||||
}
|
||||
fn search(app: &mut App, query: &str) {
|
||||
let serial = app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.query_serial()
|
||||
+ 1;
|
||||
let event = event(
|
||||
app,
|
||||
Action::Query {
|
||||
text: query.into(),
|
||||
serial,
|
||||
},
|
||||
);
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let event = event(app, Action::Choose(id));
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn raw(app: &App) -> Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
fn review(app: &App) -> &semantic_review::State {
|
||||
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
|
||||
panic!("Review is open")
|
||||
};
|
||||
state
|
||||
}
|
||||
fn send_review(app: &mut App, event: RowEvent) {
|
||||
let addressed = review(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
|
||||
}
|
||||
fn review_host(app: &App) -> Host {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
..
|
||||
} = review(app).address_event(RowEvent::Preview)
|
||||
else {
|
||||
panic!("Active facet")
|
||||
};
|
||||
Host::Review { address, facet }
|
||||
}
|
||||
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.as_u128()),
|
||||
name: "Rationale".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
|
||||
let p = catalog();
|
||||
let mut app = inspector(p.clone(), OWNER);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
// Malformed escaped text is unrelated to the selected reference. Browsing
|
||||
// and the scoped write must keep its exact spelling and inactive payload.
|
||||
app.draft.as_mut().unwrap().set_exact(true).unwrap();
|
||||
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
|
||||
let raw_before = raw(&app);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1_000
|
||||
);
|
||||
search(&mut app, "controller @controller_0720 OA");
|
||||
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
|
||||
assert_eq!(picker.count(), 1);
|
||||
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
// Captured search shortcuts cannot accidentally save/apply or undo a model.
|
||||
let _ = app.update(Message::Save(false));
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
choose(&mut app, Id::from_u128(BASE + 720));
|
||||
let mut expected_raw = raw_before;
|
||||
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
|
||||
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
|
||||
assert_eq!(raw(&app), expected_raw);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Apply);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"Unrelated invalid text remains an Apply error"
|
||||
);
|
||||
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
|
||||
let _ = app.update(Message::Apply);
|
||||
let mut expected = p;
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record {
|
||||
supertype: Some(Id::from_u128(BASE + 720)),
|
||||
};
|
||||
}
|
||||
assert_eq!(app.project(), &expected);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_ne!(accepted, before);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
|
||||
for (raw_id, expected) in [
|
||||
("-".into(), "invalid"),
|
||||
(Id::from_u128(999).to_string(), "missing"),
|
||||
(Id::from_u128(200).to_string(), "incompatible"),
|
||||
] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(raw_id))
|
||||
.unwrap();
|
||||
let original = raw(&app);
|
||||
let accepted = app.document.bundle().clone();
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let current = &app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.current;
|
||||
assert!(matches!(
|
||||
(expected, current),
|
||||
("invalid", chooser::Current::InvalidRaw(_))
|
||||
| ("missing", chooser::Current::Missing(_))
|
||||
| ("incompatible", chooser::Current::Incompatible(_))
|
||||
));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert_eq!(raw(&app), original);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let clear = event(&app, Action::Clear);
|
||||
let _ = app.update(clear);
|
||||
assert!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value
|
||||
.pointer(PATH)
|
||||
.unwrap()
|
||||
.is_null()
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
|
||||
for change in 0..7 {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
|
||||
match change {
|
||||
0 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
|
||||
.unwrap();
|
||||
}
|
||||
1 => {
|
||||
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
|
||||
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
|
||||
}
|
||||
2 => {
|
||||
app.canvas_document = Id::new_v4();
|
||||
}
|
||||
3 => {
|
||||
app.hosted.visible = true;
|
||||
}
|
||||
4 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_context(Some(json!({"different":"scope"})));
|
||||
}
|
||||
5 => {
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
Id::from_u128(BASE + 3),
|
||||
"Changed model".into(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app.source_workspace.edit(
|
||||
"unfinished source".into(),
|
||||
iced::widget::text_editor::Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 0 },
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let expected = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(delayed);
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let app = inspector(p, Id::from_u128(300));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let request = draft.reference_open(
|
||||
app.project(),
|
||||
"/baseline/semantic/facets/0/references/0/target",
|
||||
Rectangle::default(),
|
||||
);
|
||||
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
|
||||
let mut p = Project::empty();
|
||||
for n in 0..3 {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(BASE + n),
|
||||
Kind::ExchangeItem,
|
||||
Layer::Oa,
|
||||
"Signal item",
|
||||
None,
|
||||
);
|
||||
}
|
||||
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
|
||||
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
|
||||
body: EventBody::Signal {
|
||||
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
|
||||
},
|
||||
};
|
||||
let mut app = inspector(p, OWNER);
|
||||
let list = "/body/Oa/Shared/Event/body/Signal/items";
|
||||
let path = format!("{list}/0");
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
|
||||
let expected = raw(&app);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(list),
|
||||
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
|
||||
);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldVariant(
|
||||
"/body/Oa/Shared/Event/body".into(),
|
||||
json!({"Change":{"expression":"unfinished"}}),
|
||||
));
|
||||
let expected = raw(&app);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
|
||||
{
|
||||
for mode in 0..3 {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
for (id, layer, parent) in [
|
||||
(100, Layer::Oa, None),
|
||||
(101, Layer::Sa, None),
|
||||
(102, Layer::Sa, None),
|
||||
(103, Layer::Oa, Some(Id::from_u128(100))),
|
||||
(104, Layer::Sa, Some(Id::from_u128(101))),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
if parent.is_some() {
|
||||
Kind::DataField
|
||||
} else {
|
||||
Kind::DataType
|
||||
},
|
||||
layer,
|
||||
"Record",
|
||||
parent,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
let facet = match mode {
|
||||
0 => FacetSelector::Data(DataFacet::RecordSupertype),
|
||||
1 => FacetSelector::Data(DataFacet::Constructor),
|
||||
_ => FacetSelector::Child(KeyPair {
|
||||
oa: Some(Id::from_u128(103)),
|
||||
sa: Some(Id::from_u128(104)),
|
||||
}),
|
||||
};
|
||||
let path = if mode == 2 {
|
||||
"/parent"
|
||||
} else {
|
||||
"/body/Sa/Shared/DataType/definition/Record/supertype"
|
||||
};
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
|
||||
if mode == 2 {
|
||||
// A paired attachment is a real catalog facet only after explicit
|
||||
// correspondence; selecting an unstaged synthetic pair is rejected.
|
||||
for (id, oa) in [(103, true), (104, false)] {
|
||||
let choice = crate::app::Choice {
|
||||
id: Id::from_u128(id),
|
||||
label: format!("Record · @object_{id}"),
|
||||
};
|
||||
send_review(
|
||||
&mut app,
|
||||
if oa {
|
||||
RowEvent::ReferenceOa(choice)
|
||||
} else {
|
||||
RowEvent::ReferenceSa(choice)
|
||||
},
|
||||
);
|
||||
}
|
||||
send_review(&mut app, RowEvent::AddReference);
|
||||
}
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let record = if mode == 2 {
|
||||
Id::from_u128(104)
|
||||
} else {
|
||||
Id::from_u128(101)
|
||||
};
|
||||
assert_eq!(
|
||||
app.reference_draft(&host, path)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
|
||||
review(&app).selected,
|
||||
review(&app).decisions,
|
||||
review(&app).error
|
||||
)
|
||||
})
|
||||
.original,
|
||||
record
|
||||
);
|
||||
let before = review(&app).recovery_snapshot().unwrap();
|
||||
let plan = serde_json::to_value(&review(&app).plan).unwrap();
|
||||
let proposed = review(&app).proposed.clone();
|
||||
let source = app.document.bundle().clone();
|
||||
open(&mut app, &host, path);
|
||||
search(&mut app, "@object_102 SA");
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
|
||||
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
|
||||
assert_eq!(review(&app).proposed, proposed);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
let old = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
|
||||
);
|
||||
let switched = review(&app).recovery_snapshot().unwrap();
|
||||
let _ = app.update(old);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
|
||||
send_review(&mut app, RowEvent::Select(facet));
|
||||
open(&mut app, &host, path);
|
||||
choose(&mut app, Id::from_u128(102));
|
||||
let draft = app.reference_draft(&host, path).unwrap();
|
||||
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
|
||||
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
|
||||
assert_eq!(draft.materialize().unwrap(), expected_record);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"A choice only stages the active review candidate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod mounted;
|
||||
|
||||
#[test]
|
||||
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
|
||||
let mut p = Project::empty();
|
||||
for (id, layer) in [
|
||||
(100, Layer::Oa),
|
||||
(101, Layer::Sa),
|
||||
(102, Layer::Sa),
|
||||
(110, Layer::Oa),
|
||||
(111, Layer::Sa),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
Kind::DataType,
|
||||
layer,
|
||||
"Record",
|
||||
None,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(310),
|
||||
Id::from_u128(110),
|
||||
Id::from_u128(111),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
|
||||
let old_open = request(&app, &host, path);
|
||||
open(&mut app, &host, path);
|
||||
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
for event in [
|
||||
semantic_review::Event::ChooseTrace(crate::app::Choice {
|
||||
id: Id::from_u128(310),
|
||||
label: "Second exact trace".into(),
|
||||
}),
|
||||
semantic_review::Event::OpenTrace,
|
||||
] {
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
|
||||
}
|
||||
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
|
||||
let expected = review(&app).recovery_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(old_choice);
|
||||
let Host::Review { address, facet } = host else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(
|
||||
semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event: RowEvent::ReferenceOpen(old_open),
|
||||
},
|
||||
)));
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
//! Real field + app-root overlay interaction. The null text renderer lets the
|
||||
//! same retained widget tree exercise focus and geometry without a GPU/window.
|
||||
use super::*;
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Layout, Shell, layout,
|
||||
widget::{Id as WidgetId, Operation, Tree, operation},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::{Space, column, container, opaque, scrollable, stack, text_input},
|
||||
};
|
||||
use oa_core::transitions::TransitionDirection;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard;
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Fields {
|
||||
values: Vec<(WidgetId, Rectangle, bool)>,
|
||||
scrolls: Vec<(Rectangle, Vector)>,
|
||||
}
|
||||
impl Operation for Fields {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.values.push((id.clone(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.scrolls.push((bounds, translation));
|
||||
}
|
||||
}
|
||||
impl Fields {
|
||||
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
|
||||
self.values
|
||||
.iter()
|
||||
.find(|(actual, _, _)| actual == id)
|
||||
.map(|(_, bounds, focused)| (*bounds, *focused))
|
||||
.expect("Mounted field identity")
|
||||
}
|
||||
}
|
||||
fn view(
|
||||
app: &App,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
) -> Element<'_, Message, Theme, ()> {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let form = column![
|
||||
text_input("Name", &draft.inputs["/name"].raw)
|
||||
.id("before-reference")
|
||||
.on_input(|raw| Message::Field("/name".into(), raw)),
|
||||
adapter::field(draft, app.project(), PATH),
|
||||
text_input("Description", &draft.inputs["/description"].raw)
|
||||
.id("after-reference")
|
||||
.on_input(|raw| Message::Field("/description".into(), raw)),
|
||||
]
|
||||
.spacing(9);
|
||||
let form = trigger::scope(form, draft.reference_session());
|
||||
let base: Element<'_, Message, Theme, ()> = if scrolled {
|
||||
container(
|
||||
scrollable(column![Space::new().height(500), form])
|
||||
.id("reference-scroll")
|
||||
.height(180),
|
||||
)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
container(form).width(300).height(iced::Fill).into()
|
||||
};
|
||||
let base: Element<'_, Message, Theme, ()> = if nested {
|
||||
scrollable(column![Space::new().height(400), base])
|
||||
.id("outer-reference-scroll")
|
||||
.height(180)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let base = if show_review {
|
||||
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
match state.reference_form(app.project()) {
|
||||
Some(draft) => {
|
||||
let mut content = column![];
|
||||
if let Some(raw) = &state.operation_omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", raw)
|
||||
.id("review-rationale")
|
||||
.on_input(|raw| {
|
||||
Message::Workflow(workflow::Event::semantic(
|
||||
state.address_event(RowEvent::OperationRationale(raw)),
|
||||
))
|
||||
}),
|
||||
);
|
||||
}
|
||||
content = content.push(trigger::scope(
|
||||
adapter::field(draft, app.project(), "/parent").map(|message| {
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Message::Workflow(workflow::Event::semantic(
|
||||
state.address_event(RowEvent::ReferenceOpen(request)),
|
||||
))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}),
|
||||
draft.reference_session(),
|
||||
));
|
||||
opaque(container(trigger::modal(content)).width(300).height(180))
|
||||
}
|
||||
None => Space::new().into(),
|
||||
}
|
||||
}
|
||||
_ => Space::new().into(),
|
||||
};
|
||||
stack![base, review].into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
app.reference_overlay(base)
|
||||
}
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(360., 320.),
|
||||
scrolled: false,
|
||||
nested: false,
|
||||
show_review: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn frame(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Fields, Option<Rectangle>) {
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (input, point) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
input,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
input,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut fields = Fields::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut fields);
|
||||
(
|
||||
messages,
|
||||
fields,
|
||||
overlay_node.map(|node| node.children()[0].bounds()),
|
||||
)
|
||||
}
|
||||
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
|
||||
self.frame(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
async fn perform(&mut self, app: &App, task: Task<Message>) {
|
||||
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
|
||||
return;
|
||||
};
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Chooser only requests scoped widget focus")
|
||||
};
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
loop {
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), operation.as_mut());
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => operation = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
self.perform(app, task).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn key(key: keyboard::key::Named, shift: bool) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
},
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
|
||||
raw.chars()
|
||||
.map(|character| {
|
||||
let raw = character.to_string();
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(raw.clone().into()),
|
||||
modified_key: keyboard::Key::Character(raw.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(raw.into()),
|
||||
repeat: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("before-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Tab reaches the real reference trigger"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let popup = mount
|
||||
.frame(&app, &[])
|
||||
.2
|
||||
.expect("App root overlay is mounted");
|
||||
assert!(
|
||||
popup.x >= 0.
|
||||
&& popup.y >= 0.
|
||||
&& popup.x + popup.width <= 360.
|
||||
&& popup.y + popup.height <= 320.
|
||||
);
|
||||
let mut inputs = typing("@controller_0720");
|
||||
inputs.push((key(keyboard::key::Named::Escape, false), None));
|
||||
let messages = mount.frame(&app, &inputs).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Escape returns to its exact reference field"
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
|
||||
.0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&WidgetId::new(expected)).1,
|
||||
"Chooser Tab stays within the originating form"
|
||||
);
|
||||
}
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
|
||||
{
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let mut mount = Mount::default();
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("@controller_0720")).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1);
|
||||
let delayed_focus = app.update(messages.into_iter().next().unwrap());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(PATH),
|
||||
Some(&json!(Id::from_u128(BASE + 720)))
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
// A structural edit occurs after Choose but before its asynchronous focus.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, Value::Null)
|
||||
.unwrap();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
mount.perform(&app, delayed_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"A missing old field target makes no focus changes"
|
||||
);
|
||||
assert!(
|
||||
!fields
|
||||
.field(&trigger::field_id(
|
||||
app.draft.as_ref().unwrap().reference_generation(),
|
||||
PATH
|
||||
))
|
||||
.1
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
..Default::default()
|
||||
};
|
||||
mount.frame(&app, &[]);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
|
||||
)
|
||||
.await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let content = fields.field(&id).0;
|
||||
let (viewport, offset) = fields.scrolls[0];
|
||||
assert!(offset.y > 400., "Fixture is genuinely scrolled");
|
||||
let expected = Rectangle {
|
||||
x: content.x - offset.x,
|
||||
y: content.y - offset.y,
|
||||
..content
|
||||
}
|
||||
.intersection(&viewport)
|
||||
.unwrap();
|
||||
let messages = mount.click(&app, expected.center());
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Scrolled field should emit one actual Open")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let close = app.update(Message::Escape);
|
||||
mount.perform(&app, close).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Focused scrolled field opens without a pointer")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
|
||||
for nested in [false, true] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
nested,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"Keyboard reveal must not author any input"
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).1);
|
||||
assert!(
|
||||
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
|
||||
"Every enclosing viewport reveals fresh focus"
|
||||
);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
assert!(
|
||||
fields.field(&id).0.y - offset >= 180.,
|
||||
"The next reference starts below the fold"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(messages.is_empty());
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(focused);
|
||||
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
1,
|
||||
"A newly revealed field opens through Enter"
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if nested {
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"outer-reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(
|
||||
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
|
||||
for backwards in [false, true] {
|
||||
let app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let forward = [
|
||||
WidgetId::new("before-reference"),
|
||||
reference.clone(),
|
||||
WidgetId::new("after-reference"),
|
||||
];
|
||||
let reverse = [
|
||||
WidgetId::new("after-reference"),
|
||||
reference,
|
||||
WidgetId::new("before-reference"),
|
||||
];
|
||||
let expected = if backwards { &reverse } else { &forward };
|
||||
for n in 0..7 {
|
||||
let (messages, fields, popup) =
|
||||
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(fields.field(&expected[n % 3]).1);
|
||||
assert_eq!(
|
||||
fields
|
||||
.values
|
||||
.iter()
|
||||
.filter(|(_, _, focused)| *focused)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(raw(&app), original);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let source = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let session = app.draft.as_ref().unwrap().reference_session();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().reference_session(),
|
||||
session,
|
||||
"Old Inspector really remains mounted"
|
||||
);
|
||||
assert_eq!(
|
||||
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
|
||||
id
|
||||
);
|
||||
let review_before = review(&app).recovery_snapshot().unwrap();
|
||||
let review_field = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
!fields.field(&id).1,
|
||||
"A live old field underneath a modal cannot receive the stale focus task"
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert!(!fields.field(&review_field).1);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(
|
||||
fields.field(&review_field).1,
|
||||
"Review entry ignores retained focus under its modal"
|
||||
);
|
||||
assert!(
|
||||
!fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Modal focus acquisition deactivates background inputs"
|
||||
);
|
||||
let messages = mount.frame(&app, &typing("x")).0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"A character at the focused Review trigger cannot type into the Inspector below it"
|
||||
);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(fields.field(&review_field).1);
|
||||
assert!(!fields.field(&id).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let saved = review(&app).recovery_snapshot().unwrap();
|
||||
let id = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Fixture retains Inspector focus before the modal receives input"
|
||||
);
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert!(matches!(
|
||||
&app.reference_authoring.opened.as_ref().unwrap().host,
|
||||
Host::Review { .. }
|
||||
));
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
let messages = mount.click(&app, Point::new(359., 319.));
|
||||
assert!(matches!(
|
||||
messages.as_slice(),
|
||||
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
|
||||
semantic_review::Event::Row {
|
||||
event: RowEvent::ReferenceEvent(chooser::Event {
|
||||
action: Action::Close(Close::Outside),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(task).is_none(),
|
||||
"Outside dismissal must not request focus or apply"
|
||||
);
|
||||
}
|
||||
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
|
||||
{
|
||||
let mut p = persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|element| element.alias == "operation_probe_43")
|
||||
.unwrap()
|
||||
.id;
|
||||
let allocation = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|relation| relation.alias == "operation_probe_30")
|
||||
.unwrap()
|
||||
.id;
|
||||
// The boundary fixture deliberately has no selected operation. Author one
|
||||
// through the validated command path before testing reverse omission.
|
||||
let mut message = p.elements[&sa].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = message.shared_mut()
|
||||
else {
|
||||
panic!("The operation fixture must contain an Exchange call")
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(allocation),
|
||||
});
|
||||
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
|
||||
assert!(matches!(
|
||||
oa_core::operations::resolve(&p, sa).unwrap(),
|
||||
oa_core::operations::OperationResolution::Selected(value)
|
||||
if value.allocation == allocation
|
||||
));
|
||||
let inspector_id = Id::new_v4();
|
||||
element(
|
||||
&mut p,
|
||||
inspector_id,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Inspector record",
|
||||
None,
|
||||
);
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
|
||||
.unwrap();
|
||||
let oa = p
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Operational message",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(oa);
|
||||
let trace_id = Id::new_v4();
|
||||
trace(&mut p, trace_id, oa, sa);
|
||||
let mut app = inspector(p, inspector_id);
|
||||
let source = app.document.bundle().clone();
|
||||
let inspector_raw = raw(&app);
|
||||
let _ = app.update(Message::StartSemanticReview(trace_id));
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(
|
||||
semantic_review::Event::Direction(TransitionDirection::SaToOa),
|
||||
)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send_review(&mut app, RowEvent::OperationOmission(true));
|
||||
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
|
||||
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
|
||||
let candidate = serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
|
||||
review(&app).selected,
|
||||
review(&app).decisions,
|
||||
review(&app).error
|
||||
)
|
||||
})
|
||||
.recovery_snapshot(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("review-rationale")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("Engineering reason")).0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
"Engineering reason".chars().count(),
|
||||
"Every key reaches the legitimate sibling inside Review"
|
||||
);
|
||||
assert!(messages.iter().all(|message| matches!(
|
||||
message,
|
||||
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
|
||||
event: RowEvent::OperationRationale(_),
|
||||
..
|
||||
})
|
||||
)));
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
review(&app).operation_omission.as_deref(),
|
||||
Some("Engineering reason")
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.field(&WidgetId::new("review-rationale"))
|
||||
.1
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
)
|
||||
.unwrap(),
|
||||
candidate
|
||||
);
|
||||
assert_eq!(raw(&app), inspector_raw);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
|
||||
}
|
||||
fn row_event(app: &mut App, event: RowEvent) {
|
||||
let event = state(app).address_event(event);
|
||||
send(app, Message::Workflow(Event::semantic(event)));
|
||||
send(app, Message::Workflow(Event::Semantic(event)));
|
||||
}
|
||||
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -14,11 +14,9 @@ use iced::{
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
pub(crate) mod canvas_workspace;
|
||||
#[cfg(test)]
|
||||
mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
@@ -39,7 +37,6 @@ 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,10 +44,28 @@ mod scenario_scroll {
|
||||
content: content.into(),
|
||||
context,
|
||||
area,
|
||||
process,
|
||||
process: false,
|
||||
restore,
|
||||
})
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
struct Observer<'a, R = Renderer> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
context: scenario::Context,
|
||||
@@ -321,7 +336,7 @@ mod scenario_scroll {
|
||||
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
|
||||
Observer {
|
||||
context,
|
||||
area: scenario::ScrollArea::Page,
|
||||
area: scenario::ScrollArea::Canvas,
|
||||
process: false,
|
||||
restore: None,
|
||||
content: scrollable(iced::widget::Space::new().width(200.).height(height))
|
||||
@@ -391,96 +406,97 @@ mod scenario_scroll {
|
||||
std::time::Instant::now(),
|
||||
))
|
||||
};
|
||||
let area = ScrollArea::Page;
|
||||
for returned_height in [600., 250.] {
|
||||
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let source = app.document.bundle().clone();
|
||||
let mut view = observer(600., app.scenario_context().unwrap());
|
||||
view.area = area;
|
||||
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
||||
for message in update(&mut view, &mut tree, redraw()).0 {
|
||||
let _ = app.update(message);
|
||||
for area in [ScrollArea::Canvas, ScrollArea::Page] {
|
||||
for returned_height in [600., 250.] {
|
||||
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let source = app.document.bundle().clone();
|
||||
let mut view = observer(600., app.scenario_context().unwrap());
|
||||
view.area = area;
|
||||
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
||||
for message in update(&mut view, &mut tree, redraw()).0 {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
for message in update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
|
||||
}),
|
||||
)
|
||||
.0
|
||||
{
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
|
||||
view = observer(600., app.scenario_context().unwrap());
|
||||
view.area = area;
|
||||
view.diff(&mut tree);
|
||||
for message in update(&mut view, &mut tree, redraw()).0 {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
assert_eq!(app.selected, use_id);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
|
||||
view = observer(returned_height, context);
|
||||
view.area = area;
|
||||
view.restore = Some(request);
|
||||
view.diff(&mut tree);
|
||||
let expected = Vector::new(0., (returned_height - 200.).min(180.));
|
||||
let messages = update(&mut view, &mut tree, redraw()).0;
|
||||
assert!(
|
||||
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
|
||||
);
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), expected);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert!(
|
||||
app.behavior
|
||||
.scenario
|
||||
.scroll_restore(app.scenario_context().unwrap(), area)
|
||||
.is_none()
|
||||
);
|
||||
// A delayed receipt leaves the same request in the view; later input must not snap back.
|
||||
for message in update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
|
||||
}),
|
||||
)
|
||||
.0
|
||||
{
|
||||
let _ = app.update(message);
|
||||
}
|
||||
let moved = expected - Vector::new(0., 20.);
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), moved);
|
||||
view.context = app.scenario_context().unwrap();
|
||||
view.diff(&mut tree);
|
||||
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), moved);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
|
||||
});
|
||||
let late_context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::Page(crate::app::Page::Diagram));
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
|
||||
late_context,
|
||||
request.token,
|
||||
area,
|
||||
Vector::ZERO,
|
||||
)));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
for message in update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
|
||||
}),
|
||||
)
|
||||
.0
|
||||
{
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
|
||||
view = observer(600., app.scenario_context().unwrap());
|
||||
view.area = area;
|
||||
view.diff(&mut tree);
|
||||
for message in update(&mut view, &mut tree, redraw()).0 {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
assert_eq!(app.selected, use_id);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
|
||||
view = observer(returned_height, context);
|
||||
view.area = area;
|
||||
view.restore = Some(request);
|
||||
view.diff(&mut tree);
|
||||
let expected = Vector::new(0., (returned_height - 200.).min(180.));
|
||||
let messages = update(&mut view, &mut tree, redraw()).0;
|
||||
assert!(
|
||||
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
|
||||
);
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), expected);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert!(
|
||||
app.behavior
|
||||
.scenario
|
||||
.scroll_restore(app.scenario_context().unwrap(), area)
|
||||
.is_none()
|
||||
);
|
||||
// A delayed receipt leaves the same request in the view; later input must not snap back.
|
||||
for message in update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
|
||||
}),
|
||||
)
|
||||
.0
|
||||
{
|
||||
let _ = app.update(message);
|
||||
}
|
||||
let moved = expected - Vector::new(0., 20.);
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), moved);
|
||||
view.context = app.scenario_context().unwrap();
|
||||
view.diff(&mut tree);
|
||||
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), moved);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
|
||||
});
|
||||
let late_context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::Page(crate::app::Page::Diagram));
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
|
||||
late_context,
|
||||
request.token,
|
||||
area,
|
||||
Vector::ZERO,
|
||||
)));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -562,9 +578,9 @@ mod scenario_scroll {
|
||||
other.scenario = Id::new_v4();
|
||||
let mut state = topology::State::default();
|
||||
state.enter(context.document, context.scenario);
|
||||
state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.));
|
||||
state.scrolled(scenario::ScrollArea::Canvas, Vector::new(0., 180.));
|
||||
state.enter(other.document, other.scenario);
|
||||
assert_eq!(state.details_offset(), Vector::ZERO);
|
||||
assert_eq!(state.scroll_offset(), Vector::ZERO);
|
||||
view = observer(600., other);
|
||||
view.process = true;
|
||||
view.diff(&mut tree);
|
||||
@@ -581,91 +597,92 @@ mod scenario_scroll {
|
||||
{
|
||||
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
|
||||
for process in [false, true] {
|
||||
let area = scenario::ScrollArea::Page;
|
||||
for change in 0..4 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut view = observer(600., context);
|
||||
view.process = process;
|
||||
view.area = area;
|
||||
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
||||
let node = view.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
|
||||
);
|
||||
// Model a programmatic reveal before the observer has reported.
|
||||
// Mounted identity must not depend on its last scroll notification.
|
||||
let mut operation = scroll_to::<()>(
|
||||
widget::Id::new("observer-test"),
|
||||
AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
|
||||
let mut next = context;
|
||||
match change {
|
||||
0 => next.scenario = Id::new_v4(),
|
||||
1 => next.document = Id::new_v4(),
|
||||
2 => {
|
||||
next.revision += 1;
|
||||
next.epoch += 5;
|
||||
for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] {
|
||||
for change in 0..4 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut view = observer(600., context);
|
||||
view.process = process;
|
||||
view.area = area;
|
||||
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
||||
let node = view.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
|
||||
);
|
||||
// Model a programmatic reveal before the observer has reported.
|
||||
// Mounted identity must not depend on its last scroll notification.
|
||||
let mut operation = scroll_to::<()>(
|
||||
widget::Id::new("observer-test"),
|
||||
AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
|
||||
let mut next = context;
|
||||
match change {
|
||||
0 => next.scenario = Id::new_v4(),
|
||||
1 => next.document = Id::new_v4(),
|
||||
2 => {
|
||||
next.revision += 1;
|
||||
next.epoch += 5;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
view = observer(if change == 3 { 800. } else { 600. }, next);
|
||||
view.process = process;
|
||||
view.area = area;
|
||||
view.diff(&mut tree);
|
||||
let redraw = || {
|
||||
iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
))
|
||||
};
|
||||
let (messages, _) = update(&mut view, &mut tree, redraw());
|
||||
let actual = messages
|
||||
.into_iter()
|
||||
.filter_map(|message| match message {
|
||||
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
|
||||
c,
|
||||
reported_area,
|
||||
offset,
|
||||
)) => {
|
||||
assert_eq!(
|
||||
(c.document, c.owner, c.revision, c.epoch),
|
||||
(next.document, next.scenario, next.revision, next.epoch)
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&reported_area),
|
||||
std::mem::discriminant(&area)
|
||||
);
|
||||
Some(offset)
|
||||
}
|
||||
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
|
||||
c,
|
||||
reported_area,
|
||||
offset,
|
||||
)) => {
|
||||
assert_eq!(c, next);
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&reported_area),
|
||||
std::mem::discriminant(&area)
|
||||
);
|
||||
Some(offset)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![if change < 2 {
|
||||
Vector::ZERO
|
||||
} else {
|
||||
Vector::new(0., 180.)
|
||||
}],
|
||||
"process={process} area={area:?} change={change}"
|
||||
);
|
||||
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
|
||||
}
|
||||
view = observer(if change == 3 { 800. } else { 600. }, next);
|
||||
view.process = process;
|
||||
view.area = area;
|
||||
view.diff(&mut tree);
|
||||
let redraw = || {
|
||||
iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
))
|
||||
};
|
||||
let (messages, _) = update(&mut view, &mut tree, redraw());
|
||||
let actual = messages
|
||||
.into_iter()
|
||||
.filter_map(|message| match message {
|
||||
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
|
||||
c,
|
||||
reported_area,
|
||||
offset,
|
||||
)) => {
|
||||
assert_eq!(
|
||||
(c.document, c.owner, c.revision, c.epoch),
|
||||
(next.document, next.scenario, next.revision, next.epoch)
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&reported_area),
|
||||
std::mem::discriminant(&area)
|
||||
);
|
||||
Some(offset)
|
||||
}
|
||||
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
|
||||
c,
|
||||
reported_area,
|
||||
offset,
|
||||
)) => {
|
||||
assert_eq!(c, next);
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&reported_area),
|
||||
std::mem::discriminant(&area)
|
||||
);
|
||||
Some(offset)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![if change < 2 {
|
||||
Vector::ZERO
|
||||
} else {
|
||||
Vector::new(0., 180.)
|
||||
}],
|
||||
"process={process} area={area:?} change={change}"
|
||||
);
|
||||
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1124,26 +1141,12 @@ struct BoxItem {
|
||||
detail: String,
|
||||
marker: Option<Kind>,
|
||||
}
|
||||
fn box_text_clip(
|
||||
pan: iced::Vector,
|
||||
rect: Rectangle,
|
||||
scale: f32,
|
||||
viewport: Size,
|
||||
) -> Option<Rectangle> {
|
||||
if !pan.x.is_finite()
|
||||
|| !pan.y.is_finite()
|
||||
|| !scale.is_finite()
|
||||
|| scale <= 0.
|
||||
|| rect.width <= 20.
|
||||
|| rect.height <= 16.
|
||||
{
|
||||
fn box_text_clip(rect: Rectangle, scale: f32, viewport: Size) -> Option<Rectangle> {
|
||||
if !scale.is_finite() || scale <= 0. || rect.width <= 20. || rect.height <= 16. {
|
||||
return None;
|
||||
}
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
pan.x + (rect.x + 10.) * scale,
|
||||
pan.y + (rect.y + 8.) * scale,
|
||||
),
|
||||
Point::new((rect.x + 10.) * scale, (rect.y + 8.) * scale),
|
||||
Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale),
|
||||
)
|
||||
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
|
||||
@@ -1162,7 +1165,7 @@ struct Scene {
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
camera_pan: iced::Vector,
|
||||
scroll_offset: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
@@ -1217,7 +1220,7 @@ impl Scene {
|
||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(700., 200.),
|
||||
@@ -1928,7 +1931,8 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
// Scrollable marks a pointer outside its visible viewport unavailable,
|
||||
// even when it lies inside the full (scrolled) canvas rectangle.
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(
|
||||
@@ -1951,7 +1955,7 @@ impl canvas::Program<Message> for Scene {
|
||||
SequencePress::Occurrence { .. } => {}
|
||||
}
|
||||
}
|
||||
mouse::Cursor::Available(*position)
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
|
||||
| canvas::Event::Window(iced::window::Event::Resized(_))
|
||||
@@ -1963,9 +1967,11 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position();
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
@@ -1977,12 +1983,9 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
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 world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if !visible {
|
||||
@@ -2002,14 +2005,14 @@ impl canvas::Program<Message> for Scene {
|
||||
state.press = Some(SequencePress::Message {
|
||||
source,
|
||||
slot,
|
||||
start: cursor.position()?,
|
||||
start: cursor.position()? - self.scroll_offset,
|
||||
first,
|
||||
moved: false,
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let CanvasTool::Add(tool) = tool {
|
||||
if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline {
|
||||
if !tool.canvas_occurrence() {
|
||||
return None;
|
||||
}
|
||||
state.press = Some(SequencePress::Occurrence {
|
||||
@@ -2019,19 +2022,11 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
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)
|
||||
})
|
||||
{
|
||||
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
|
||||
state.press = Some(SequencePress::Reorder {
|
||||
occurrence,
|
||||
parent: self.rows[&occurrence].0,
|
||||
start: cursor.position()?,
|
||||
occurrence: self.selected,
|
||||
parent: self.rows[&self.selected].0,
|
||||
start: cursor.position()? - self.scroll_offset,
|
||||
moved: false,
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
@@ -2105,18 +2100,6 @@ 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,
|
||||
@@ -2154,7 +2137,6 @@ 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);
|
||||
self.lifecycle.draw_lanes(&mut f, self.selected);
|
||||
for b in &self.boxes {
|
||||
@@ -2235,11 +2217,10 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
for b in &self.boxes {
|
||||
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
|
||||
if let Some(clip) = box_text_clip(b.rect, self.scale, f.size()) {
|
||||
// Clipped child frames start with an identity transform. Match
|
||||
// the card's pan and scale, as for lifecycle Create labels.
|
||||
// the card's scale explicitly, as for lifecycle Create labels.
|
||||
f.with_clip(clip, |label| {
|
||||
label.translate(self.camera_pan);
|
||||
label.scale(self.scale);
|
||||
label.fill_text(Text {
|
||||
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
||||
@@ -2274,15 +2255,12 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
}
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
.draw_markers(&mut f, self.selected, self.scale);
|
||||
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.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let point = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.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 {
|
||||
@@ -2327,18 +2305,6 @@ 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(
|
||||
@@ -2544,26 +2510,25 @@ mod direct_canvas_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() {
|
||||
fn direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() {
|
||||
let (f, mut scene) = scene();
|
||||
scene.camera_pan = iced::Vector::new(-35., -140.);
|
||||
scene.scroll_offset = iced::Vector::new(35., 140.);
|
||||
let bounds = Rectangle::new(Point::new(20., 230.), scene.size);
|
||||
let slot = scene.slots.last().unwrap().slot;
|
||||
let y = scene.slots.last().unwrap().y;
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan;
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) - scene.scroll_offset;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset;
|
||||
let mut state = SequenceInput::default();
|
||||
// Batched pointer events use their own positions with the final cursor.
|
||||
// Iced Scrollable passes raw event coordinates and a translated cursor.
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
] {
|
||||
send(&scene, &mut state, event, b, bounds);
|
||||
send(&scene, &mut state, event, b + scene.scroll_offset, bounds);
|
||||
}
|
||||
assert!(
|
||||
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
|
||||
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
//! 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(crate) 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(crate) 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;
|
||||
@@ -1,256 +0,0 @@
|
||||
//! 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))
|
||||
) && !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)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
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();
|
||||
Headless::screenshot(
|
||||
&mut self.renderer,
|
||||
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")
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -886,7 +886,7 @@ fn contextual_source_incomplete_recovered_kind_reports_error_without_panicking()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_layer() {
|
||||
fn contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer() {
|
||||
use crate::app::{App, Message, Page};
|
||||
let (mut p, a) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity);
|
||||
let b = p
|
||||
@@ -898,32 +898,30 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
let context = app.scenario_context().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
|
||||
context,
|
||||
tool: scenario::Tool::Lifeline,
|
||||
slot: scenario::Slot {
|
||||
parent: a,
|
||||
before: None,
|
||||
},
|
||||
lifeline: None,
|
||||
};
|
||||
app.behavior.active_owner = Some(b);
|
||||
app.selected = b;
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
|
||||
context,
|
||||
scenario::Tool::Lifeline,
|
||||
)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.project(), &p);
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
app.layer = Layer::Sa;
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
|
||||
context,
|
||||
scenario::Tool::Lifeline,
|
||||
)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
app.layer = Layer::Oa;
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(current)));
|
||||
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
|
||||
current,
|
||||
scenario::Tool::Lifeline,
|
||||
)));
|
||||
assert_eq!(app.behavior.pending_owner(), Some(a));
|
||||
assert_eq!(app.project(), &p);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Keep behavior field state within one editing session and reset it on replacement.
|
||||
use iced::advanced::widget::{Operation, Tree, tree};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
use oa_core::Id;
|
||||
|
||||
struct Identity(Id);
|
||||
|
||||
struct Session<'a, Message, R: renderer::Renderer> {
|
||||
identity: Id,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
identity: Id,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Session {
|
||||
identity,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Identity>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Identity(self.identity))
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let retained = tree.state.downcast_mut::<Identity>();
|
||||
if retained.0 != self.identity {
|
||||
retained.0 = self.identity;
|
||||
// Iced's keyed column preserves same-length child replacements.
|
||||
// An editing-session replacement must instead get fresh field state.
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
pub(crate) mod focus;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -96,12 +94,11 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
First(Context, Tool),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
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),
|
||||
@@ -129,18 +126,18 @@ pub enum CanvasEvent {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScrollArea {
|
||||
// The independent Details/outline scroller; viewport motion uses Camera.
|
||||
Canvas,
|
||||
// Retained navigation channel, now the independent Selection/outline shelf.
|
||||
Page,
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(context, _, _)
|
||||
Self::First(context, _)
|
||||
| Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
@@ -203,14 +200,11 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
form_session: Id,
|
||||
form_mount: Id,
|
||||
form_focus_pending: bool,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||||
details_offset: iced::Vector,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<Caller>,
|
||||
restore: Option<DetailsRestore>,
|
||||
restore: Option<ViewportRestore>,
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
const CALLER_LIMIT: usize = 32;
|
||||
@@ -220,14 +214,15 @@ pub(crate) struct Caller {
|
||||
pub occurrence: Id,
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
details_offset: iced::Vector,
|
||||
offsets: [iced::Vector; 2],
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
struct DetailsRestore {
|
||||
struct ViewportRestore {
|
||||
document: Id,
|
||||
owner: Id,
|
||||
token: Id,
|
||||
offset: iced::Vector,
|
||||
offsets: [iced::Vector; 2],
|
||||
pending: [bool; 2],
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ScrollRestore {
|
||||
@@ -287,18 +282,6 @@ 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())
|
||||
}
|
||||
@@ -318,11 +301,7 @@ impl State {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
returned
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
@@ -332,38 +311,16 @@ impl State {
|
||||
focused: self.presentation.focused,
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -392,11 +349,11 @@ impl State {
|
||||
occurrence,
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
details_offset: self.details_offset,
|
||||
offsets: self.scroll_offsets,
|
||||
presentation: self.presentation.clone(),
|
||||
});
|
||||
self.canvas_tool = Some((target, CanvasTool::Select));
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
Ok(target)
|
||||
}
|
||||
@@ -423,30 +380,34 @@ impl State {
|
||||
return;
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.details_offset = frame.details_offset;
|
||||
self.scroll_offsets = frame.offsets;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.restore = Some(DetailsRestore {
|
||||
self.restore = Some(ViewportRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
token: Id::new_v4(),
|
||||
offset: frame.details_offset,
|
||||
offsets: frame.offsets,
|
||||
pending: [true; 2],
|
||||
});
|
||||
}
|
||||
pub(super) fn scroll_restore(
|
||||
&self,
|
||||
context: Context,
|
||||
ScrollArea::Page: ScrollArea,
|
||||
area: ScrollArea,
|
||||
) -> Option<ScrollRestore> {
|
||||
let restore = self
|
||||
.restore
|
||||
.as_ref()
|
||||
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let restore = self.restore.as_ref().filter(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.pending[index]
|
||||
})?;
|
||||
Some(ScrollRestore {
|
||||
document: restore.document,
|
||||
owner: restore.owner,
|
||||
token: restore.token,
|
||||
offset: restore.offset,
|
||||
offset: restore.offsets[index],
|
||||
})
|
||||
}
|
||||
pub(crate) fn restored(
|
||||
@@ -456,12 +417,22 @@ impl State {
|
||||
area: ScrollArea,
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !self.restore.as_ref().is_some_and(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.token == token
|
||||
}) {
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let Some(restore) = self.restore.as_mut().filter(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.token == token
|
||||
&& r.pending[index]
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
restore.pending[index] = false;
|
||||
if !restore.pending.contains(&true) {
|
||||
self.restore = None;
|
||||
}
|
||||
self.restore = None;
|
||||
self.scrolled(area, offset)
|
||||
}
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
@@ -474,57 +445,28 @@ impl State {
|
||||
|
||||
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
||||
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
let session = Id::new_v4();
|
||||
Ok(Self {
|
||||
draft: Some(draft),
|
||||
form_session: session,
|
||||
form_mount: session,
|
||||
form_focus_pending: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn form_session(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|_| self.form_session)
|
||||
}
|
||||
|
||||
pub(crate) fn form_mount(&self) -> Option<Id> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.filter(|d| !d.source.is_open())
|
||||
.map(|_| self.form_mount)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
|
||||
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
|
||||
context,
|
||||
self.form_session,
|
||||
self.form_mount()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn remount_form(&mut self) {
|
||||
self.form_mount = Id::new_v4();
|
||||
self.form_focus_pending = true;
|
||||
}
|
||||
|
||||
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
|
||||
if self.form_mount() == Some(mount) && self.form_focus_pending {
|
||||
self.form_focus_pending = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
|
||||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
}
|
||||
let changed = self.details_offset != offset;
|
||||
self.details_offset = offset;
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
// The former page scroll now belongs to a sibling outline shelf.
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
|
||||
let key = CallChoiceContext {
|
||||
document: context.document,
|
||||
@@ -674,7 +616,6 @@ impl State {
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
self.remount_form();
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
@@ -715,11 +656,7 @@ impl State {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
let returned = d.source.is_open() && !next.source.is_open();
|
||||
self.draft = Some(next);
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
@@ -734,9 +671,6 @@ impl State {
|
||||
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.form_session = Id::new_v4();
|
||||
self.form_mount = self.form_session;
|
||||
self.form_focus_pending = true;
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
@@ -2052,31 +1986,20 @@ pub(crate) mod direct_tests {
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
let camera_epoch = app.camera_epoch;
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Camera(
|
||||
ctx,
|
||||
camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
|
||||
)
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
|
||||
assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
let camera_epoch = app.camera_epoch;
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Camera(
|
||||
ctx,
|
||||
camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
|
||||
)
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
|
||||
assert_eq!(app.selected, c);
|
||||
@@ -2087,7 +2010,7 @@ pub(crate) mod direct_tests {
|
||||
CanvasTool::Message(MessageStyle::Timer)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(0., 115.)
|
||||
);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
@@ -2098,11 +2021,11 @@ pub(crate) mod direct_tests {
|
||||
CanvasTool::Add(Tool::Reuse)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 170.)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
app.behavior.scenario.scroll_offsets[1],
|
||||
iced::Vector::new(0., 80.),
|
||||
"The caller's independent outline scroll is restored separately"
|
||||
);
|
||||
@@ -2280,24 +2203,22 @@ pub(crate) mod direct_tests {
|
||||
let before = app.document.bundle().clone();
|
||||
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
||||
scene.interaction = Some((context, CanvasTool::Add(tool), true));
|
||||
scene.camera_pan = iced::Vector::new(23., 80.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(20., 40.),
|
||||
Size::new(scene.size.width + 46., scene.size.height + 160.),
|
||||
);
|
||||
scene.scroll_offset = iced::Vector::new(23., 80.);
|
||||
let bounds = Rectangle::new(Point::new(20., 40.), scene.size);
|
||||
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
|
||||
let x = if on_lifeline {
|
||||
scene.lanes[&f.sender]
|
||||
} else {
|
||||
scene.size.width - 35.
|
||||
};
|
||||
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
|
||||
assert!(bounds.contains(point));
|
||||
let point = Point::new(bounds.x + x, bounds.y + y);
|
||||
let mut input = SequenceInput::default();
|
||||
canvas_mouse(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
mouse::Event::CursorMoved {
|
||||
position: point - scene.scroll_offset,
|
||||
},
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
@@ -2473,10 +2394,10 @@ pub(crate) mod direct_tests {
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
let old_context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
old_context,
|
||||
app.camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
|
||||
ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 100.),
|
||||
)));
|
||||
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
@@ -2667,29 +2588,26 @@ pub(crate) mod direct_tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
|
||||
fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() {
|
||||
let f = fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
|
||||
)));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
)));
|
||||
for (area, offset) in [
|
||||
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
|
||||
(ScrollArea::Page, iced::Vector::new(0., 40.)),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
assert!(app.busy);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
);
|
||||
assert_ne!(
|
||||
app.scenario_context(),
|
||||
@@ -2700,14 +2618,14 @@ pub(crate) mod direct_tests {
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
ScrollArea::Page,
|
||||
ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
);
|
||||
let root = std::path::PathBuf::from("scenario-details-save-fixture");
|
||||
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
|
||||
let receipt = crate::app::FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
@@ -2728,17 +2646,12 @@ pub(crate) mod direct_tests {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
||||
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
iced::Vector::new(-25., -100.)
|
||||
);
|
||||
scene.camera_pan = app.behavior.scenario.presentation.pan;
|
||||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||||
let slot = scene.slots.last().unwrap().slot;
|
||||
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
|
||||
let y = bounds.y + scene.slots.last().unwrap().y;
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
|
||||
let mut input = super::super::SequenceInput::default();
|
||||
let mut published = vec![];
|
||||
for event in [
|
||||
@@ -2752,7 +2665,7 @@ pub(crate) mod direct_tests {
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(b),
|
||||
mouse::Cursor::Available(b + scene.scroll_offset),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
{
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
//! A delayed focus request must still own the live parent tree when executed.
|
||||
//! Collect first: a missing target must not unfocus unrelated fields.
|
||||
use super::Context;
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
|
||||
use oa_core::Id as ModelId;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Stamp {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
}
|
||||
impl Stamp {
|
||||
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
))
|
||||
}
|
||||
struct Owner(Option<Stamp>);
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
owner: Option<Stamp>,
|
||||
}
|
||||
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Stamp>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
content: content.into(),
|
||||
owner,
|
||||
})
|
||||
}
|
||||
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,
|
||||
op: &mut dyn Operation,
|
||||
) {
|
||||
// This is computed from the current App view, never retained widget state.
|
||||
op.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, op);
|
||||
}
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Collect {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending: Option<Scroll>,
|
||||
index: usize,
|
||||
target: Option<(Rectangle, Vec<Scroll>)>,
|
||||
}
|
||||
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
stamp,
|
||||
valid: false,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending: None,
|
||||
index: 0,
|
||||
target: None,
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.scrolls.len());
|
||||
if let Some(scroll) = self.pending.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.translation = previous.0;
|
||||
self.scrolls.truncate(previous.1);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending = Some(Scroll {
|
||||
index: self.index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.index += 1;
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.valid {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let Some((mut bounds, ancestors)) = self.target.clone() else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in ancestors.iter().rev() {
|
||||
let dx = reveal(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
// Drawing offsets are rounded; an already-visible axis must not
|
||||
// acquire a scroll delta from a fractional capacity alone.
|
||||
let delta = Vector::new(
|
||||
if dx == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
|
||||
},
|
||||
if dy == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
|
||||
},
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
stamp: self.stamp,
|
||||
valid: false,
|
||||
shifts,
|
||||
index: 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
|
||||
if start < viewport || length > size {
|
||||
start - viewport
|
||||
} else {
|
||||
(start + length - viewport - size).max(0.)
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
index: usize,
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.valid {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& let Some(delta) = self.shifts.get(&self.index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
//! Scenario presentation. Layout/camera controls never construct model commands.
|
||||
use super::super::{form_session, scenario_scroll};
|
||||
use super::super::scenario_scroll;
|
||||
use super::*;
|
||||
use iced::widget::{Space, column, sensor};
|
||||
|
||||
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Shelf {
|
||||
Tools,
|
||||
Selection,
|
||||
}
|
||||
|
||||
@@ -16,7 +15,6 @@ 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,19 +44,23 @@ impl Presentation {
|
||||
}
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// Fit uses the actual mounted viewport with a small visual gutter.
|
||||
// This is the measured inner Scrollable, never a window-height estimate.
|
||||
// Leave room for the overlay scrollbars on both axes.
|
||||
Some(
|
||||
((viewport.width - 16.).max(1.) / scene.width.max(1.))
|
||||
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
|
||||
)
|
||||
}
|
||||
pub(super) fn scale(&self, scene: Size) -> f32 {
|
||||
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> {
|
||||
@@ -74,188 +76,249 @@ 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 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 size = Size::new(scene.size.width * scale, scene.size.height * scale);
|
||||
let canvas = canvas_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Canvas),
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
);
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
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
|
||||
button(
|
||||
text(if presentation.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.padding(6)
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context)))
|
||||
]
|
||||
.spacing(6);
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
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))),
|
||||
));
|
||||
navigation = navigation.push(
|
||||
button(text("Back to caller").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
),
|
||||
);
|
||||
}
|
||||
let header = column![
|
||||
let mut 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,
|
||||
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
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
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)))
|
||||
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 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,
|
||||
))),
|
||||
));
|
||||
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 limited {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
|
||||
}
|
||||
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),
|
||||
sequence_rows(p, context, owner, details, clean).into(),
|
||||
);
|
||||
let mut form_panel = editor_panel(
|
||||
Id::nil(),
|
||||
Space::new().height(0).into(),
|
||||
Space::new().height(0).into(),
|
||||
details.into(),
|
||||
presentation.shelf.is_some(),
|
||||
pending,
|
||||
);
|
||||
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.scenario
|
||||
@@ -279,9 +342,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.id(focus::name_id(
|
||||
state.focus_stamp(context).expect("visible parent form")
|
||||
))
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
@@ -559,20 +619,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
let session = state.form_session().expect("visible scenario draft");
|
||||
let mount = state.form_mount().expect("visible parent form");
|
||||
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
|
||||
.key(mount)
|
||||
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
|
||||
.into();
|
||||
form_panel = editor_panel(footer.into(), form.into());
|
||||
}
|
||||
}
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
presentation.shelf.map(|_| details)
|
||||
};
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
workspace_layout(header.into(), details, canvas, form_panel, pending)
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
@@ -586,38 +636,101 @@ 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(Fill),
|
||||
.height(if open {
|
||||
if pending { 72. } else { 100. }
|
||||
} else {
|
||||
0.
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
context: Context,
|
||||
restore: Option<ScrollRestore>,
|
||||
content: iced::Element<'a, Message, Theme, R>,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
container(
|
||||
sensor(scenario_scroll::observe(
|
||||
context,
|
||||
ScrollArea::Canvas,
|
||||
restore,
|
||||
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>,
|
||||
fields: iced::Element<'a, Message, Theme, R>,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
container(
|
||||
column![
|
||||
container(actions).id("scenario-actions").width(Fill),
|
||||
form_session::reset_on_change(
|
||||
session,
|
||||
scrollable(fields)
|
||||
.id("scenario-form")
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
),
|
||||
scrollable(fields)
|
||||
.id("scenario-form")
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
|
||||
@@ -277,13 +277,7 @@ impl Rendering {
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn draw_markers(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
pan: iced::Vector,
|
||||
) {
|
||||
pub(in crate::behavior) fn draw_markers(&self, frame: &mut Frame, selected: Id, scale: f32) {
|
||||
for marker in &self.markers {
|
||||
let color = if selected == marker.event.occurrence || selected == marker.lifeline {
|
||||
style::TEAL
|
||||
@@ -308,11 +302,10 @@ impl Rendering {
|
||||
},
|
||||
),
|
||||
);
|
||||
if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
|
||||
if let Some(clip) = birth_label_clip(marker.capsule, scale, frame.size()) {
|
||||
// WGPU clipped frames start with an identity transform;
|
||||
// neither the parent's pan/scale nor its clip is inherited.
|
||||
// neither the parent's scale nor its clip is inherited.
|
||||
frame.with_clip(clip, |label| {
|
||||
label.translate(pan);
|
||||
label.scale(scale);
|
||||
label.fill_text(Text {
|
||||
content: short(&marker.name, 18),
|
||||
@@ -337,20 +330,12 @@ impl Rendering {
|
||||
}
|
||||
}
|
||||
|
||||
fn birth_label_clip(
|
||||
pan: iced::Vector,
|
||||
capsule: Rectangle,
|
||||
scale: f32,
|
||||
viewport: Size,
|
||||
) -> Option<Rectangle> {
|
||||
if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
|
||||
fn birth_label_clip(capsule: Rectangle, scale: f32, viewport: Size) -> Option<Rectangle> {
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
pan.x + (capsule.x + 8.) * scale,
|
||||
pan.y + (capsule.y + 4.) * scale,
|
||||
),
|
||||
Point::new((capsule.x + 8.) * scale, (capsule.y + 4.) * scale),
|
||||
Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
|
||||
)
|
||||
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
|
||||
|
||||
@@ -194,13 +194,9 @@ fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
|
||||
// Scroll offset is handled by the existing outer canvas/scrollable;
|
||||
// these are the local drawing frame's coordinates, not screen input.
|
||||
for scale in [0.05, 0.5, 0.512, 0.64, 0.8, 1., 1.953125, 2.] {
|
||||
let clip = crate::behavior::box_text_clip(
|
||||
iced::Vector::ZERO,
|
||||
header.rect,
|
||||
scale,
|
||||
Size::new(2000., 2000.),
|
||||
)
|
||||
.unwrap();
|
||||
let clip =
|
||||
crate::behavior::box_text_clip(header.rect, scale, Size::new(2000., 2000.))
|
||||
.unwrap();
|
||||
let world = Rectangle::new(
|
||||
Point::new(clip.x / scale, clip.y / scale),
|
||||
Size::new(clip.width / scale, clip.height / scale),
|
||||
@@ -225,12 +221,11 @@ fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() {
|
||||
use crate::behavior::box_text_clip;
|
||||
let card = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
|
||||
assert_eq!(
|
||||
box_text_clip(iced::Vector::ZERO, card, 2., Size::new(300., 500.)),
|
||||
box_text_clip(card, 2., Size::new(300., 500.)),
|
||||
Some(Rectangle::new(Point::new(220., 416.), Size::new(80., 84.)))
|
||||
);
|
||||
assert_eq!(
|
||||
box_text_clip(
|
||||
iced::Vector::ZERO,
|
||||
Rectangle::new(Point::new(-20., -10.), card.size()),
|
||||
2.,
|
||||
Size::new(300., 500.),
|
||||
@@ -238,22 +233,14 @@ fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() {
|
||||
Some(Rectangle::new(Point::ORIGIN, Size::new(300., 104.)))
|
||||
);
|
||||
for viewport in [Size::new(200., 500.), Size::new(300., 400.), Size::ZERO] {
|
||||
assert_eq!(box_text_clip(iced::Vector::ZERO, card, 2., viewport), None);
|
||||
assert_eq!(box_text_clip(card, 2., viewport), None);
|
||||
}
|
||||
for scale in [0., -1., f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(
|
||||
box_text_clip(iced::Vector::ZERO, card, scale, Size::new(1000., 1000.)),
|
||||
None
|
||||
);
|
||||
assert_eq!(box_text_clip(card, scale, Size::new(1000., 1000.)), None);
|
||||
}
|
||||
for size in [Size::new(20., 70.), Size::new(204., 16.), Size::ZERO] {
|
||||
assert_eq!(
|
||||
box_text_clip(
|
||||
iced::Vector::ZERO,
|
||||
Rectangle::new(Point::ORIGIN, size),
|
||||
1.,
|
||||
card.size()
|
||||
),
|
||||
box_text_clip(Rectangle::new(Point::ORIGIN, size), 1., card.size()),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -317,8 +304,7 @@ fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
|
||||
Rectangle::new(Point::new(216., 408.), Size::new(248., 40.)),
|
||||
),
|
||||
] {
|
||||
let clip =
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)).unwrap();
|
||||
let clip = birth_label_clip(capsule, scale, Size::new(1000., 1000.)).unwrap();
|
||||
for (actual, expected) in [
|
||||
(clip.x, expected.x),
|
||||
(clip.y, expected.y),
|
||||
@@ -339,28 +325,20 @@ fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
|
||||
fn birth_label_clip_preserves_parent_viewport_bounds() {
|
||||
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
|
||||
assert_eq!(
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, 2., Size::new(300., 420.)),
|
||||
birth_label_clip(capsule, 2., Size::new(300., 420.)),
|
||||
Some(Rectangle::new(Point::new(216., 408.), Size::new(84., 12.)))
|
||||
);
|
||||
let crossing_origin = Rectangle::new(Point::new(-20., -10.), Size::new(140., 28.));
|
||||
assert_eq!(
|
||||
birth_label_clip(
|
||||
iced::Vector::ZERO,
|
||||
crossing_origin,
|
||||
2.,
|
||||
Size::new(300., 420.)
|
||||
),
|
||||
birth_label_clip(crossing_origin, 2., Size::new(300., 420.)),
|
||||
Some(Rectangle::new(Point::ORIGIN, Size::new(224., 28.)))
|
||||
);
|
||||
for viewport in [Size::new(200., 420.), Size::new(300., 400.), Size::ZERO] {
|
||||
assert_eq!(
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, 2., viewport),
|
||||
None
|
||||
);
|
||||
assert_eq!(birth_label_clip(capsule, 2., viewport), None);
|
||||
}
|
||||
for scale in [0., -1., f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)),
|
||||
birth_label_clip(capsule, scale, Size::new(1000., 1000.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -816,158 +794,3 @@ fn real_scene_lifecycle_edit_undo_redo_restores_exact_source_and_changed_geometr
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panned_header_and_create_clips_use_screen_translation_and_actual_viewport() {
|
||||
use crate::behavior::box_text_clip;
|
||||
use iced::Vector;
|
||||
let header = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
|
||||
let capsule = Rectangle::new(header.position(), Size::new(140., 28.));
|
||||
let rect = |x, y, w, h| Rectangle::new(Point::new(x, y), Size::new(w, h));
|
||||
for (pan, scale, viewport, header_expected, birth_expected) in [
|
||||
(
|
||||
Vector::new(37., -41.),
|
||||
0.5,
|
||||
Size::new(300., 400.),
|
||||
rect(92., 63., 92., 27.),
|
||||
rect(91., 61., 62., 10.),
|
||||
),
|
||||
(
|
||||
Vector::new(37., -41.),
|
||||
1.,
|
||||
Size::new(300., 400.),
|
||||
rect(147., 167., 153., 54.),
|
||||
rect(145., 163., 124., 20.),
|
||||
),
|
||||
(
|
||||
Vector::new(37., -41.),
|
||||
2.,
|
||||
Size::new(300., 400.),
|
||||
rect(257., 375., 43., 25.),
|
||||
rect(253., 367., 47., 33.),
|
||||
),
|
||||
(
|
||||
Vector::new(-200., -400.),
|
||||
2.,
|
||||
Size::new(300., 100.),
|
||||
rect(20., 16., 280., 84.),
|
||||
rect(16., 8., 248., 40.),
|
||||
),
|
||||
(
|
||||
Vector::new(-237., -411.),
|
||||
2.,
|
||||
Size::new(300., 100.),
|
||||
rect(0., 5., 300., 95.),
|
||||
rect(0., 0., 227., 37.),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
box_text_clip(pan, header, scale, viewport),
|
||||
Some(header_expected)
|
||||
);
|
||||
assert_eq!(
|
||||
birth_label_clip(pan, capsule, scale, viewport),
|
||||
Some(birth_expected)
|
||||
);
|
||||
}
|
||||
for pan in [
|
||||
Vector::new(1000., 1000.),
|
||||
Vector::new(-1000., -1000.),
|
||||
Vector::new(f32::NAN, 0.),
|
||||
Vector::new(0., f32::INFINITY),
|
||||
] {
|
||||
assert_eq!(box_text_clip(pan, header, 2., Size::new(300., 100.)), None);
|
||||
assert_eq!(
|
||||
birth_label_clip(pan, capsule, 2., Size::new(300., 100.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity() {
|
||||
use crate::{
|
||||
app::Message,
|
||||
behavior::{
|
||||
SequenceInput,
|
||||
scenario::{CanvasEvent, CanvasTool},
|
||||
},
|
||||
};
|
||||
use iced::{Vector, mouse, widget::canvas};
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
f.project.elements.get_mut(&f.receiver).unwrap().name = "界".repeat(24);
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
let delete = f.message(f.owner, MessageKind::Delete);
|
||||
let document = f.document();
|
||||
let before = document.snapshot();
|
||||
for scale in [0.5, 1., 2.] {
|
||||
for pan in [Vector::new(35., 40.), Vector::new(-35., -5.)] {
|
||||
let mut scene = f.scene();
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = pan;
|
||||
let context = crate::behavior::scenario::direct_tests::context(f.owner);
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let header = scene.boxes.iter().find(|b| b.id == f.sender).unwrap();
|
||||
let targets = [
|
||||
(f.sender, header.rect.center()),
|
||||
(
|
||||
create,
|
||||
scene
|
||||
.lifecycle
|
||||
.markers
|
||||
.iter()
|
||||
.find(|m| m.event.occurrence == create)
|
||||
.unwrap()
|
||||
.center,
|
||||
),
|
||||
(
|
||||
delete,
|
||||
scene
|
||||
.lifecycle
|
||||
.markers
|
||||
.iter()
|
||||
.find(|m| m.event.occurrence == delete)
|
||||
.unwrap()
|
||||
.center,
|
||||
),
|
||||
];
|
||||
// Absolute widget origin models the outer layout/scroll translation;
|
||||
// camera pan remains local screen units and must be subtracted once.
|
||||
let bounds = Rectangle::new(Point::new(73., 119.), Size::new(2000., 1200.));
|
||||
for (expected, world) in targets {
|
||||
let screen =
|
||||
bounds.position() + pan + Vector::new(world.x * scale, world.y * scale);
|
||||
assert!(bounds.contains(screen));
|
||||
let mut state = SequenceInput::default();
|
||||
let mut published = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: screen },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
if let Some(message) = canvas::Program::update(
|
||||
&scene,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
{
|
||||
published.push(message);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!(published.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Select(actual_context, actual))] if *actual_context == context && *actual == expected),
|
||||
"Pan/zoom must retain the typed hit identity"
|
||||
);
|
||||
}
|
||||
assert_eq!(scene.lifecycle, f.scene().lifecycle);
|
||||
}
|
||||
}
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert!(!document.can_undo() && !document.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub enum Event {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Shelf {
|
||||
Tools,
|
||||
Selection,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -79,11 +80,10 @@ pub struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
details_offset: iced::Vector,
|
||||
return_to: Vec<direct::ReturnFrame>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
pub(crate) focused: bool,
|
||||
@@ -125,18 +125,6 @@ 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())
|
||||
}
|
||||
@@ -350,8 +338,8 @@ impl State {
|
||||
}
|
||||
|
||||
fn fit_area(&self, fallback_width: f32) -> Size {
|
||||
// The sensor measures the viewport, independent of graph content.
|
||||
// Leave a small content gutter; before first layout use a compact
|
||||
// The sensor measures the inner scroller, independent of graph content.
|
||||
// Leave a small gutter for scrollbars; before first layout use a compact
|
||||
// provisional viewport and recompute when the sensor reports actual size.
|
||||
let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.));
|
||||
Size::new(
|
||||
@@ -360,7 +348,10 @@ impl State {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn canvas_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -978,8 +969,7 @@ mod contextual_routing;
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
camera_pan: iced::Vector,
|
||||
pending_insertion: Option<Insertion>,
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
ordered_paths: Vec<OrderedPath>,
|
||||
@@ -1311,8 +1301,7 @@ impl<'a> Graph<'a> {
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
ordered_paths,
|
||||
@@ -1412,7 +1401,6 @@ 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 {
|
||||
@@ -1628,10 +1616,7 @@ 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.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
||||
self.nodes.iter().any(|n| n.rect.contains(p))
|
||||
|| self.ordered_paths.iter().any(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
@@ -2164,6 +2149,7 @@ mod tests {
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.behavior.prepare_pending(app.project()).is_err());
|
||||
for (shelf, expected) in [
|
||||
(Shelf::Tools, Some(Shelf::Tools)),
|
||||
(Shelf::Selection, Some(Shelf::Selection)),
|
||||
(Shelf::Selection, None),
|
||||
] {
|
||||
@@ -2180,12 +2166,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_camera_selection_ignores_independent_details_scroll() {
|
||||
fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() {
|
||||
let (p, owner, _, _, _, _) = fixture();
|
||||
let mut state = State::default();
|
||||
state.camera(crate::behavior::canvas_workspace::Event::Pan(
|
||||
iced::Vector::new(11., 19.),
|
||||
));
|
||||
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.));
|
||||
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.));
|
||||
let context = Context {
|
||||
document: p.id,
|
||||
@@ -2196,17 +2180,16 @@ mod tests {
|
||||
for scale in [0.45, 1.] {
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
graph.scale = scale;
|
||||
graph.camera_pan = state.pan;
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
let node = &graph.nodes[0];
|
||||
let id = node.id;
|
||||
let point = node.rect.center();
|
||||
let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.));
|
||||
let screen = Point::new(
|
||||
bounds.x + point.x * scale + 11.,
|
||||
bounds.y + point.y * scale + 19.,
|
||||
bounds.x + point.x * scale - 11.,
|
||||
bounds.y + point.y * scale - 19.,
|
||||
);
|
||||
assert!(bounds.contains(screen));
|
||||
let message = graph
|
||||
.input(
|
||||
&mut Input::default(),
|
||||
|
||||
@@ -608,11 +608,7 @@ mod tests {
|
||||
"Authored endpoint is retained for explicit repair"
|
||||
);
|
||||
assert!(state.prepare_pending(&deleted).is_err());
|
||||
state.remember(
|
||||
owner,
|
||||
reference,
|
||||
super::super::direct::referenced(&p, owner, reference).unwrap(),
|
||||
);
|
||||
state.remember(owner, reference);
|
||||
assert_eq!(state.back(&deleted), Some((owner, reference)));
|
||||
assert!(state.selected_context.is_none());
|
||||
state.draft = None;
|
||||
|
||||
@@ -60,7 +60,6 @@ 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),
|
||||
}
|
||||
@@ -78,7 +77,6 @@ impl CanvasEvent {
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Camera(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
@@ -88,19 +86,6 @@ 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);
|
||||
@@ -153,49 +138,35 @@ 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;
|
||||
self.expanded.clear();
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn details_offset(&self) -> iced::Vector {
|
||||
self.details_offset
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
pub(crate) fn scrolled(
|
||||
&mut self,
|
||||
scenario::ScrollArea::Page: scenario::ScrollArea,
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
}
|
||||
let changed = self.details_offset != offset;
|
||||
self.details_offset = offset;
|
||||
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
scenario::ScrollArea::Canvas => 0,
|
||||
scenario::ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||
// 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.05, 3.));
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
}
|
||||
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
|
||||
@@ -217,73 +188,24 @@ impl State {
|
||||
}
|
||||
true
|
||||
}
|
||||
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 remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
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()
|
||||
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()
|
||||
});
|
||||
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));
|
||||
return Some((owner, 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
|
||||
@@ -625,7 +547,7 @@ impl Graph<'_> {
|
||||
{
|
||||
*moved |= position.distance(*start) >= 3.;
|
||||
}
|
||||
mouse::Cursor::Available(*position)
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(
|
||||
@@ -644,19 +566,18 @@ impl Graph<'_> {
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position();
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
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 world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.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 => {
|
||||
@@ -670,7 +591,7 @@ impl Graph<'_> {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let start = cursor.position()?;
|
||||
let start = cursor.position()? - self.scroll_offset;
|
||||
match tool {
|
||||
CanvasTool::Add(..) => {
|
||||
state.press = Some(Press::Add {
|
||||
@@ -809,35 +730,15 @@ 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;
|
||||
}
|
||||
let point = state
|
||||
.pointer
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| {
|
||||
Point::new(
|
||||
(p.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
@@ -952,7 +853,12 @@ impl Graph<'_> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
@@ -964,25 +870,10 @@ 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));
|
||||
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))
|
||||
.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.),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +540,7 @@ fn send(
|
||||
input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
mouse::Cursor::Available(point + graph.scroll_offset),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
@@ -554,20 +554,16 @@ fn position(graph: &Graph<'_>, id: Id) -> Point {
|
||||
.center()
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_production_click_click_drag_insert_and_camera_coordinates() {
|
||||
fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for drag in [false, true] {
|
||||
let mut graph = graph(&f, CanvasTool::Connect);
|
||||
graph.camera_pan = iced::Vector::new(35., 90.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(10., 20.),
|
||||
Size::new(graph.size.width + 70., graph.size.height + 180.),
|
||||
);
|
||||
graph.scroll_offset = iced::Vector::new(35., 90.);
|
||||
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
|
||||
let screen =
|
||||
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) + graph.camera_pan;
|
||||
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
|
||||
let a = screen(f.nodes[0]);
|
||||
let b = screen(f.nodes[2]);
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
@@ -736,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.1);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
|
||||
let mut all = Vec::new();
|
||||
@@ -1335,9 +1331,7 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
let document = Id::new_v4();
|
||||
state.enter(document, f.owner);
|
||||
state.viewport(Size::new(500., 300.));
|
||||
state.camera(super::super::canvas_workspace::Event::Pan(
|
||||
iced::Vector::new(35., 90.),
|
||||
));
|
||||
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(35., 90.));
|
||||
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.));
|
||||
for camera in [None, Some(0.75), Some(1.5)] {
|
||||
state.zoom(camera);
|
||||
@@ -1359,16 +1353,90 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
"{layer:?} {camera:?} {tool:?}"
|
||||
);
|
||||
assert_eq!(state.fit, camera.is_none());
|
||||
assert_eq!(state.pan, iced::Vector::new(35., 90.));
|
||||
assert_eq!(state.details_offset(), iced::Vector::new(0., 120.));
|
||||
assert_eq!(state.scroll_offset(), iced::Vector::new(35., 210.));
|
||||
state.enter(document, f.owner);
|
||||
assert_eq!(state.scale(size, 500.), before);
|
||||
}
|
||||
}
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.);
|
||||
assert_eq!(state.pan, iced::Vector::ZERO);
|
||||
assert_eq!(state.details_offset(), iced::Vector::ZERO);
|
||||
assert_eq!(state.scroll_offset(), iced::Vector::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,18 +1447,15 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
for scale in [0.15, 0.6898551, 1.5] {
|
||||
let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice));
|
||||
graph.scale = scale;
|
||||
graph.camera_pan = iced::Vector::new(11., 19.);
|
||||
graph.scroll_offset = iced::Vector::new(11., 19.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(40., 70.),
|
||||
Size::new(
|
||||
graph.size.width * scale + 22.,
|
||||
graph.size.height * scale + 38.,
|
||||
),
|
||||
Size::new(graph.size.width * scale, graph.size.height * scale),
|
||||
);
|
||||
let screen = |world: Point| {
|
||||
Point::new(
|
||||
world.x * scale + bounds.x + 11.,
|
||||
world.y * scale + bounds.y + 19.,
|
||||
world.x * scale + bounds.x - 11.,
|
||||
world.y * scale + bounds.y - 19.,
|
||||
)
|
||||
};
|
||||
let edge = &graph.edges[0];
|
||||
@@ -1398,7 +1463,6 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
(edge.points[0].x + edge.points[1].x) / 2.,
|
||||
edge.points[0].y,
|
||||
));
|
||||
assert!(bounds.contains(point));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
@@ -1429,7 +1493,6 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
let mut input = Input::default();
|
||||
let a = screen(position(&graph, f.nodes[0]));
|
||||
let b = screen(position(&graph, f.nodes[2]));
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
|
||||
@@ -66,7 +66,107 @@ 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 clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
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))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -111,6 +211,11 @@ 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))]
|
||||
@@ -151,79 +256,27 @@ 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))),
|
||||
);
|
||||
}
|
||||
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)),
|
||||
))),
|
||||
let diagram = {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
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(
|
||||
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),
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
@@ -232,104 +285,121 @@ 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)
|
||||
.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)
|
||||
.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(),
|
||||
]
|
||||
.spacing(6),
|
||||
super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
event(Event::Shelf(Shelf::Selection)),
|
||||
clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
))),
|
||||
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
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),
|
||||
)
|
||||
]
|
||||
.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();
|
||||
.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(
|
||||
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))),
|
||||
)
|
||||
.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,
|
||||
)
|
||||
};
|
||||
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) {
|
||||
@@ -547,37 +617,77 @@ 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(d.invocation, actions.into(), form.into());
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
}
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
state.shelf.map(|_| details)
|
||||
};
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
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()
|
||||
}
|
||||
|
||||
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![
|
||||
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),
|
||||
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct Document {
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
current_bytes: usize,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
saved: Arc<SourceBundle>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
@@ -125,7 +125,7 @@ impl Document {
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
saved: Some(Arc::new(bundle)),
|
||||
saved: Arc::new(bundle),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
@@ -156,7 +156,7 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
||||
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
|
||||
}
|
||||
|
||||
/// Recovered work is a detached copy until explicitly saved by its user.
|
||||
@@ -181,24 +181,10 @@ impl Document {
|
||||
/// Records the bytes actually saved, even when later edits remain current.
|
||||
/// The I/O adapter owns document identity and save completion ordering.
|
||||
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
||||
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
||||
self.saved = Arc::new(snapshot.bundle.clone());
|
||||
self.recovered_unsaved = false;
|
||||
}
|
||||
|
||||
/// Bind an installed or recovered repair destination to source read there.
|
||||
/// These bytes may be invalid or differ from the retained accepted model,
|
||||
/// but must replace the previous disk baseline. Accepted history is retained;
|
||||
/// applying or undoing to old saved content does not save this destination.
|
||||
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
||||
self.saved = Some(Arc::new(source));
|
||||
}
|
||||
|
||||
/// Recovery changed the current destination, but its source could not be
|
||||
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
||||
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
||||
self.saved = None;
|
||||
}
|
||||
|
||||
/// Check a source Save as before writing any files. A retained included file
|
||||
/// must never be displaced by the new entry, including on a later Undo.
|
||||
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
||||
|
||||
@@ -18,7 +18,6 @@ mod context_fields;
|
||||
mod data_fields;
|
||||
mod history_fields;
|
||||
mod port_fields;
|
||||
pub(crate) mod reference_chooser;
|
||||
mod view_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -86,8 +85,6 @@ pub struct Draft {
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
|
||||
history: history_fields::State,
|
||||
reference_session: Id,
|
||||
reference_generation: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -212,14 +209,11 @@ impl Draft {
|
||||
value_browsers: BTreeMap::new(),
|
||||
reference_browsers: BTreeMap::new(),
|
||||
history: Default::default(),
|
||||
reference_session: Id::new_v4(),
|
||||
reference_generation: Id::new_v4(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
self.reference_generation = Id::new_v4();
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
@@ -280,9 +274,6 @@ impl Draft {
|
||||
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
|
||||
}
|
||||
pub fn set_context(&mut self, context: Option<Value>) {
|
||||
if self.context != context {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
self.context = context;
|
||||
}
|
||||
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||
@@ -368,9 +359,6 @@ impl Draft {
|
||||
}
|
||||
pub fn input(&mut self, path: &str, value: String) {
|
||||
if let Some(input) = self.inputs.get_mut(path) {
|
||||
if input.raw != value {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
input.editor = text_editor::Content::with_text(&value);
|
||||
input.raw = value;
|
||||
}
|
||||
@@ -378,11 +366,7 @@ impl Draft {
|
||||
pub fn edit_text(&mut self, path: &str, action: text_editor::Action) {
|
||||
if let Some(input) = self.inputs.get_mut(path) {
|
||||
input.editor.perform(action);
|
||||
let raw = input.editor.text();
|
||||
if input.raw != raw {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
input.raw = raw;
|
||||
input.raw = input.editor.text();
|
||||
}
|
||||
}
|
||||
pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
@@ -1811,7 +1795,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
));
|
||||
}
|
||||
}
|
||||
reference_chooser::trigger::scope(content, draft.reference_session)
|
||||
content.into()
|
||||
}
|
||||
|
||||
/// Render one closed property subtree for a workflow-owned typed draft.
|
||||
@@ -1953,12 +1937,41 @@ fn property<'a>(
|
||||
return out.into();
|
||||
}
|
||||
if reference(path) && (value.is_string() || value.is_null()) {
|
||||
return column![
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| value.as_str());
|
||||
let current = raw.and_then(|s| s.parse::<Id>().ok()).unwrap_or(Id::nil());
|
||||
let choices = reference_choices(draft, project, path, current);
|
||||
let selected = choices.iter().find(|c| c.id == current).cloned();
|
||||
let owned_path = path.to_string();
|
||||
let optional = optional_reference(path);
|
||||
let mut content = column![
|
||||
label_view(),
|
||||
reference_chooser::adapter::field(draft, project, path)
|
||||
pick_list(choices, selected, move |choice| Message::FieldShape(
|
||||
owned_path.clone(),
|
||||
if choice.id.is_nil() && optional {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(choice.id)
|
||||
}
|
||||
))
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(5)
|
||||
.into();
|
||||
.spacing(5);
|
||||
if let Some(raw) = raw
|
||||
&& raw.parse::<Id>().is_err()
|
||||
{
|
||||
content = content.push(
|
||||
text(format!("Invalid reference retained in the draft: {raw}"))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
return content.into();
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
|
||||
@@ -670,7 +670,7 @@ mod tests {
|
||||
}
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) {
|
||||
let event = semantic_state(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(event)));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
}
|
||||
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
|
||||
use crate::semantic_review::RowEvent as Event;
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
//! Isolated live-reference chooser experiment, declared only under cfg(test).
|
||||
//! The host supplies an eligible catalog and revalidates every
|
||||
//! Write intent against the current document before changing a staged scalar.
|
||||
//! No core command, draft serialization, or App message belongs in this module.
|
||||
use oa_core::Id;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub mod adapter;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod trigger;
|
||||
pub mod widget;
|
||||
|
||||
pub const MAX_ROWS: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub revision: u64,
|
||||
pub draft: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub optional: bool,
|
||||
}
|
||||
impl Context {
|
||||
/// This is an additional historical-path guard, not live schema discovery.
|
||||
/// Call only after the parent form has identified a live reference field.
|
||||
pub fn live(
|
||||
document: Id,
|
||||
revision: u64,
|
||||
draft: Id,
|
||||
record: Id,
|
||||
path: impl Into<String>,
|
||||
optional: bool,
|
||||
) -> Result<Self, &'static str> {
|
||||
let path = path.into();
|
||||
if !path.starts_with('/') || path.split('/').any(|part| part == "baseline") {
|
||||
return Err("The chooser requires a live reference property.");
|
||||
}
|
||||
Ok(Self {
|
||||
document,
|
||||
revision,
|
||||
draft,
|
||||
record,
|
||||
path,
|
||||
optional,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
/// For a diagram placement this is the placement ID, not its element ID.
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub alias: String,
|
||||
pub kind: String,
|
||||
pub scope: String,
|
||||
pub containment: String,
|
||||
}
|
||||
impl Candidate {
|
||||
pub fn context_label(&self) -> String {
|
||||
format!("{} · {} · {}", self.scope, self.kind, self.containment)
|
||||
}
|
||||
pub fn detail(&self) -> String {
|
||||
format!(
|
||||
"{} · @{} · {} · {}",
|
||||
self.name,
|
||||
self.alias,
|
||||
self.context_label(),
|
||||
self.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Current {
|
||||
Unassigned,
|
||||
Valid(Candidate),
|
||||
Incompatible(Candidate),
|
||||
Missing(Id),
|
||||
InvalidRaw(String),
|
||||
}
|
||||
impl Current {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Unassigned => "Not assigned".into(),
|
||||
Self::Valid(c) => format!("Current: {} · @{}", c.name, c.alias),
|
||||
Self::Incompatible(c) => {
|
||||
format!("Current choice needs review: {} · @{}", c.name, c.alias)
|
||||
}
|
||||
Self::Missing(id) => format!("Missing reference: {id}"),
|
||||
Self::InvalidRaw(raw) => format!("Invalid reference retained: {raw}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Searchable {
|
||||
candidate: Candidate,
|
||||
all: String,
|
||||
alias: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Stamp {
|
||||
pub context: Context,
|
||||
pub invocation: Id,
|
||||
pub results: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Close {
|
||||
Escape,
|
||||
Tab { backwards: bool },
|
||||
Outside,
|
||||
WindowChanged,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The mounted text input numbers emitted edits. Multiple edits in one
|
||||
/// runtime batch may share the old result stamp; none may be dropped.
|
||||
Query {
|
||||
text: String,
|
||||
serial: u64,
|
||||
},
|
||||
Move {
|
||||
delta: i32,
|
||||
rows: usize,
|
||||
},
|
||||
Page {
|
||||
delta: i32,
|
||||
rows: usize,
|
||||
},
|
||||
Highlight(Id),
|
||||
Choose(Id),
|
||||
Clear,
|
||||
Close(Close),
|
||||
}
|
||||
impl Action {
|
||||
pub fn writes_staged_value(&self) -> bool {
|
||||
matches!(self, Self::Choose(_) | Self::Clear)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Event {
|
||||
pub stamp: Stamp,
|
||||
pub action: Action,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Ignored,
|
||||
Browsed,
|
||||
Closed(Close),
|
||||
/// The host must still check current eligibility and scoped draft guards.
|
||||
Write {
|
||||
context: Context,
|
||||
value: Option<Id>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
context: Context,
|
||||
invocation: Id,
|
||||
results: u64,
|
||||
serial: u64,
|
||||
open: bool,
|
||||
candidates: Vec<Searchable>,
|
||||
filtered: Vec<usize>,
|
||||
query: String,
|
||||
start: usize,
|
||||
highlight: Option<usize>,
|
||||
pub current: Current,
|
||||
pub scope_label: String,
|
||||
}
|
||||
impl State {
|
||||
pub fn open(
|
||||
context: Context,
|
||||
candidates: Vec<Candidate>,
|
||||
current: Current,
|
||||
scope_label: impl Into<String>,
|
||||
) -> Result<Self, &'static str> {
|
||||
// Context fields are public for host comparisons; check again in case
|
||||
// a caller constructed one without the live-path constructor.
|
||||
if !context.path.starts_with('/') || context.path.split('/').any(|p| p == "baseline") {
|
||||
return Err("Historical values are not live reference candidates.");
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut indexed = Vec::with_capacity(candidates.len());
|
||||
for candidate in candidates {
|
||||
if candidate.id.is_nil() || !seen.insert(candidate.id) {
|
||||
return Err("Eligible references must have distinct, non-nil identities.");
|
||||
}
|
||||
indexed.push(Searchable {
|
||||
all: candidate.detail().to_lowercase(),
|
||||
alias: candidate.alias.to_lowercase(),
|
||||
name: candidate.name.to_lowercase(),
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
indexed.sort_by(|a, b| {
|
||||
(&a.name, &a.alias, a.candidate.id).cmp(&(&b.name, &b.alias, b.candidate.id))
|
||||
});
|
||||
let filtered: Vec<_> = (0..indexed.len()).collect();
|
||||
let highlight = (!filtered.is_empty()).then_some(0);
|
||||
Ok(Self {
|
||||
context,
|
||||
invocation: Id::new_v4(),
|
||||
results: 0,
|
||||
serial: 0,
|
||||
open: true,
|
||||
candidates: indexed,
|
||||
filtered,
|
||||
query: String::new(),
|
||||
start: 0,
|
||||
highlight,
|
||||
current,
|
||||
scope_label: scope_label.into(),
|
||||
})
|
||||
}
|
||||
pub fn stamp(&self) -> Stamp {
|
||||
Stamp {
|
||||
context: self.context.clone(),
|
||||
invocation: self.invocation,
|
||||
results: self.results,
|
||||
}
|
||||
}
|
||||
pub fn event(&self, action: Action) -> Event {
|
||||
Event {
|
||||
stamp: self.stamp(),
|
||||
action,
|
||||
}
|
||||
}
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
pub fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
pub fn query_serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
pub fn count(&self) -> usize {
|
||||
self.filtered.len()
|
||||
}
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
pub fn optional(&self) -> bool {
|
||||
self.context.optional
|
||||
}
|
||||
pub fn highlighted(&self) -> Option<&Candidate> {
|
||||
self.highlight.and_then(|i| self.at(i))
|
||||
}
|
||||
fn at(&self, index: usize) -> Option<&Candidate> {
|
||||
self.filtered
|
||||
.get(index)
|
||||
.map(|i| &self.candidates[*i].candidate)
|
||||
}
|
||||
pub fn visible(&self, rows: usize) -> impl Iterator<Item = &Candidate> {
|
||||
self.filtered
|
||||
.iter()
|
||||
.skip(self.start)
|
||||
.take(rows.min(MAX_ROWS))
|
||||
.map(|index| &self.candidates[*index].candidate)
|
||||
}
|
||||
/// The host calls this before rendering on any path/document/facet/context
|
||||
/// replacement. Existing events remain tied to the old invocation.
|
||||
pub fn invalidate(&mut self) {
|
||||
self.open = false;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: Event) -> Outcome {
|
||||
if !self.open
|
||||
|| event.stamp.context != self.context
|
||||
|| event.stamp.invocation != self.invocation
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
// Absolute row actions require exactly the presented result set.
|
||||
// Relative navigation and dismissal are processed in input order within
|
||||
// this session: typing must not make a following Escape stale.
|
||||
if matches!(event.action, Action::Choose(_) | Action::Highlight(_))
|
||||
&& event.stamp.results != self.results
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
match event.action {
|
||||
Action::Query { text, serial } => {
|
||||
if serial <= self.serial {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.serial = serial;
|
||||
self.query = text;
|
||||
let query = self.query.to_lowercase();
|
||||
let terms: Vec<_> = query.split_whitespace().collect();
|
||||
self.filtered = self
|
||||
.candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
terms
|
||||
.iter()
|
||||
.all(|term| match term.strip_prefix('@') {
|
||||
Some(alias) => c.alias.contains(alias),
|
||||
None => c.all.contains(term),
|
||||
})
|
||||
.then_some(i)
|
||||
})
|
||||
.collect();
|
||||
self.start = 0;
|
||||
self.highlight = (!self.filtered.is_empty()).then_some(0);
|
||||
}
|
||||
Action::Page { delta, rows } => {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
if self.filtered.is_empty() {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
let relative = self
|
||||
.highlight
|
||||
.unwrap_or(self.start)
|
||||
.saturating_sub(self.start)
|
||||
.min(rows - 1);
|
||||
let last_page = (self.filtered.len() - 1) / rows * rows;
|
||||
self.start = (self.start as i64 + i64::from(delta) * rows as i64)
|
||||
.clamp(0, last_page as i64) as usize;
|
||||
self.highlight = Some((self.start + relative).min(self.filtered.len() - 1));
|
||||
}
|
||||
Action::Move { delta, rows } => {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
if self.filtered.is_empty() {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
let current = self.highlight.unwrap_or(self.start) as i64;
|
||||
let next =
|
||||
(current + i64::from(delta)).clamp(0, self.filtered.len() as i64 - 1) as usize;
|
||||
self.highlight = Some(next);
|
||||
if next < self.start {
|
||||
self.start = next;
|
||||
}
|
||||
if next >= self.start + rows {
|
||||
self.start = next + 1 - rows;
|
||||
}
|
||||
}
|
||||
Action::Highlight(id) => {
|
||||
let Some(index) = self
|
||||
.filtered
|
||||
.iter()
|
||||
.position(|i| self.candidates[*i].candidate.id == id)
|
||||
else {
|
||||
return Outcome::Ignored;
|
||||
};
|
||||
if self.highlight == Some(index) {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.highlight = Some(index);
|
||||
}
|
||||
Action::Choose(id) => {
|
||||
if !self
|
||||
.filtered
|
||||
.iter()
|
||||
.any(|i| self.candidates[*i].candidate.id == id)
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.open = false;
|
||||
return Outcome::Write {
|
||||
context: self.context.clone(),
|
||||
value: Some(id),
|
||||
};
|
||||
}
|
||||
Action::Clear if self.context.optional => {
|
||||
self.open = false;
|
||||
return Outcome::Write {
|
||||
context: self.context.clone(),
|
||||
value: None,
|
||||
};
|
||||
}
|
||||
Action::Clear => return Outcome::Ignored,
|
||||
Action::Close(reason) => {
|
||||
self.open = false;
|
||||
return Outcome::Closed(reason);
|
||||
}
|
||||
}
|
||||
self.results = self.results.wrapping_add(1);
|
||||
Outcome::Browsed
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
//! Live form adapter. Compatibility stays in the existing parent adapter;
|
||||
//! browser metadata and scoped writes never reinterpret historical UUID text.
|
||||
use super::super::{Draft, friendly, history_fields, optional_reference, reference, reference_ids};
|
||||
use super::{Candidate, Context, Current, State, trigger};
|
||||
use crate::{app::Message, style};
|
||||
use iced::{
|
||||
Element, Fill, Rectangle,
|
||||
widget::{column, container, row, text, tooltip},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Open {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub generation: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub anchor: Rectangle,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Set {
|
||||
pub context: Context,
|
||||
pub generation: Id,
|
||||
pub value: Option<Id>,
|
||||
}
|
||||
impl Draft {
|
||||
pub fn reference_session(&self) -> Id {
|
||||
self.reference_session
|
||||
}
|
||||
pub fn reference_generation(&self) -> Id {
|
||||
self.reference_generation
|
||||
}
|
||||
pub fn reference_open(&self, project: &Project, path: &str, anchor: Rectangle) -> Open {
|
||||
Open {
|
||||
document: project.id,
|
||||
session: self.reference_session,
|
||||
generation: self.reference_generation,
|
||||
record: self.original,
|
||||
path: path.into(),
|
||||
anchor,
|
||||
}
|
||||
}
|
||||
pub fn reference_matches(&self, request: &Open) -> bool {
|
||||
self.reference_session == request.session
|
||||
&& self.reference_generation == request.generation
|
||||
&& self.original == request.record
|
||||
&& self.live_reference(&request.path)
|
||||
}
|
||||
fn live_reference(&self, path: &str) -> bool {
|
||||
!history_fields::is_path(path)
|
||||
&& reference(path)
|
||||
&& self
|
||||
.value
|
||||
.pointer(path)
|
||||
.is_some_and(|v| v.is_string() || v.is_null())
|
||||
}
|
||||
pub fn reference_catalog(
|
||||
&self,
|
||||
project: &Project,
|
||||
request: &Open,
|
||||
revision: u64,
|
||||
) -> Result<State, String> {
|
||||
if request.document != project.id || !self.reference_matches(request) {
|
||||
return Err("The reference field changed. Open its chooser again.".into());
|
||||
}
|
||||
let allowed: BTreeSet<_> = reference_ids(self, project, &request.path)
|
||||
.into_iter()
|
||||
.filter(|id| !id.is_nil())
|
||||
.collect();
|
||||
let candidates = allowed
|
||||
.iter()
|
||||
.filter_map(|id| candidate(project, *id))
|
||||
.collect();
|
||||
let raw = self
|
||||
.inputs
|
||||
.get(&request.path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| self.value.pointer(&request.path).and_then(Value::as_str));
|
||||
let current = match raw {
|
||||
None => Current::Unassigned,
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => Current::Unassigned,
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) if allowed.contains(&id) => Current::Valid(c),
|
||||
Some(c) => Current::Incompatible(c),
|
||||
None => Current::Missing(id),
|
||||
},
|
||||
Err(_) => Current::InvalidRaw(raw.into()),
|
||||
},
|
||||
};
|
||||
let context = Context::live(
|
||||
project.id,
|
||||
revision,
|
||||
self.reference_session,
|
||||
self.original,
|
||||
&request.path,
|
||||
optional_reference(&request.path),
|
||||
)
|
||||
.map_err(str::to_owned)?;
|
||||
State::open(
|
||||
context,
|
||||
candidates,
|
||||
current,
|
||||
format!(
|
||||
"{} · compatible references",
|
||||
friendly(request.path.rsplit('/').next().unwrap_or("Reference"))
|
||||
),
|
||||
)
|
||||
.map_err(str::to_owned)
|
||||
}
|
||||
pub fn reference_set(&mut self, project: &Project, intent: &Set) -> Result<(), String> {
|
||||
let value = self.reference_value(project, intent)?;
|
||||
self.shape_scoped(&intent.context.path, value)
|
||||
}
|
||||
pub fn reference_value(&self, project: &Project, intent: &Set) -> Result<Value, String> {
|
||||
let context = &intent.context;
|
||||
if context.document != project.id
|
||||
|| context.draft != self.reference_session
|
||||
|| context.record != self.original
|
||||
|| intent.generation != self.reference_generation
|
||||
|| !self.live_reference(&context.path)
|
||||
|| context.optional != optional_reference(&context.path)
|
||||
{
|
||||
return Err("The originating reference field is no longer current.".into());
|
||||
}
|
||||
let value = match intent.value {
|
||||
Some(id)
|
||||
if !id.is_nil() && reference_ids(self, project, &context.path).contains(&id) =>
|
||||
{
|
||||
json!(id)
|
||||
}
|
||||
None if context.optional => Value::Null,
|
||||
_ => return Err("Choose a currently compatible reference.".into()),
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn named(project: &Project, id: Id) -> String {
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
format!("{} · @{}", e.name, e.alias)
|
||||
} else {
|
||||
project.named(id).into()
|
||||
}
|
||||
}
|
||||
fn ancestry(project: &Project, id: Id) -> String {
|
||||
let mut parts = vec![];
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
parts.push("[containment cycle]".into());
|
||||
break;
|
||||
}
|
||||
parts.push(named(project, id));
|
||||
current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
parts.reverse();
|
||||
if parts.is_empty() {
|
||||
project.name.clone()
|
||||
} else {
|
||||
parts.join(" / ")
|
||||
}
|
||||
}
|
||||
pub(crate) fn candidate(project: &Project, id: Id) -> Option<Candidate> {
|
||||
let layer = |layer| format!("{layer:?}").to_uppercase();
|
||||
if id == project.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.name.clone(),
|
||||
alias: project.alias.clone(),
|
||||
kind: "Project".into(),
|
||||
scope: "Model".into(),
|
||||
containment: "Model root".into(),
|
||||
});
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: e.name.clone(),
|
||||
alias: e.alias.clone(),
|
||||
kind: e.kind().to_string(),
|
||||
scope: layer(e.layer()),
|
||||
containment: ancestry(project, id),
|
||||
});
|
||||
}
|
||||
if let Some(r) = project.relations.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: r.name.clone(),
|
||||
alias: r.alias.clone(),
|
||||
kind: r.kind().to_string(),
|
||||
scope: project.layer_of(id).map_or_else(|| "Model".into(), layer),
|
||||
containment: format!(
|
||||
"{} → {}",
|
||||
named(project, r.source()),
|
||||
named(project, r.target())
|
||||
),
|
||||
});
|
||||
}
|
||||
for diagram in project.diagrams.values() {
|
||||
if id == diagram.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: diagram.name.clone(),
|
||||
alias: diagram.alias.clone(),
|
||||
kind: format!("{:?} diagram", diagram.projection),
|
||||
scope: layer(diagram.layer),
|
||||
containment: project.name.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(node) = diagram.nodes.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.named(node.element).into(),
|
||||
alias: node.alias.clone(),
|
||||
kind: "Placement".into(),
|
||||
scope: layer(diagram.layer),
|
||||
containment: format!(
|
||||
"{} · @{} / {}",
|
||||
diagram.name,
|
||||
diagram.alias,
|
||||
named(project, node.element)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(link) = project.artifact_links.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: link.name.clone(),
|
||||
alias: link.alias.clone(),
|
||||
kind: "Artifact link".into(),
|
||||
scope: project
|
||||
.layer_of(link.owner)
|
||||
.map_or_else(|| "Model".into(), layer),
|
||||
containment: named(project, link.owner),
|
||||
});
|
||||
}
|
||||
project.traces.get(&id).map(|trace| Candidate {
|
||||
id,
|
||||
name: trace.name.clone(),
|
||||
alias: trace.alias.clone(),
|
||||
kind: "Trace".into(),
|
||||
scope: "OA ↔ SA".into(),
|
||||
containment: format!(
|
||||
"{} ↔ {}",
|
||||
named(project, trace.oa),
|
||||
named(project, trace.sa)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn field<'a, R: iced::advanced::text::Renderer<Font = iced::Font> + 'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
path: &str,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| draft.value.pointer(path).and_then(Value::as_str));
|
||||
let (label, detail) = match raw {
|
||||
None => (
|
||||
if optional_reference(path) {
|
||||
"Not assigned"
|
||||
} else {
|
||||
"Choose a reference…"
|
||||
}
|
||||
.into(),
|
||||
String::new(),
|
||||
),
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => ("Choose a reference…".into(), String::new()),
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) => (format!("{} · @{}", c.name, c.alias), c.detail()),
|
||||
None => ("Missing reference".into(), raw.into()),
|
||||
},
|
||||
Err(_) => ("Invalid reference retained".into(), raw.into()),
|
||||
},
|
||||
};
|
||||
let label = crate::views::short(&label, 35);
|
||||
let body = container(row![text(label).size(12).width(Fill), text("⌄").size(12)].spacing(6))
|
||||
.padding(8)
|
||||
.width(Fill);
|
||||
let path = path.to_owned();
|
||||
let id = trigger::field_id(draft.reference_generation, &path);
|
||||
let field = trigger::Field::new(body, id, move |anchor| {
|
||||
Message::FieldReferenceOpen(draft.reference_open(project, &path, anchor))
|
||||
});
|
||||
if detail.is_empty() {
|
||||
field.into()
|
||||
} else {
|
||||
tooltip(
|
||||
field,
|
||||
container(column![text(detail).size(11)])
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
tooltip::Position::Left,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -1,803 +0,0 @@
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{Layout, Shell, layout, widget::Tree},
|
||||
keyboard, mouse,
|
||||
widget::{Space, mouse_area},
|
||||
};
|
||||
|
||||
fn context(optional: bool) -> Context {
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
7,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
"/body/SequenceUse/sequence",
|
||||
optional,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn candidate(index: u128) -> Candidate {
|
||||
Candidate {
|
||||
id: Id::from_u128(10_000 + index),
|
||||
name: "Control".into(),
|
||||
alias: format!("controller_{index:04}"),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: format!("System / Subsystem {} / Controller", index / 100),
|
||||
}
|
||||
}
|
||||
fn thousand(current: Current, optional: bool) -> State {
|
||||
// Reverse the fixture input to falsify accidental dependence on ingestion
|
||||
// order. Expected IDs and aliases below are literal fixture identities.
|
||||
State::open(
|
||||
context(optional),
|
||||
(0..1_000).rev().map(candidate).collect(),
|
||||
current,
|
||||
"SA · compatible functions",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Chooser(Event),
|
||||
UnderPress,
|
||||
UnderRelease,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard(Option<String>);
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
self.0.clone()
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
|
||||
self.0 = Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
anchor: Rectangle,
|
||||
clipboard: Clipboard,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(1100., 700.),
|
||||
anchor: Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
clipboard: Clipboard::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn batch(
|
||||
&mut self,
|
||||
state: Option<&State>,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Option<layout::Node>) {
|
||||
let underneath: Element<'_, Message, Theme, ()> =
|
||||
mouse_area(Space::new().width(iced::Fill).height(iced::Fill))
|
||||
.on_press(Message::UnderPress)
|
||||
.on_release(Message::UnderRelease)
|
||||
.into();
|
||||
let mut view: Element<'_, Message, Theme, ()> =
|
||||
widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into();
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (event, point) in events {
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
// Reconstruct the overlay between layout and every input, as
|
||||
// Iced does. Geometry must live in the measured layout tree.
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
event,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
overlay.as_overlay().draw(
|
||||
&mut (),
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: iced::Color::BLACK,
|
||||
},
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
);
|
||||
if matches!(
|
||||
event,
|
||||
Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_)
|
||||
) {
|
||||
assert!(shell.is_event_captured(), "An open chooser owns input");
|
||||
}
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
(messages, overlay_node)
|
||||
}
|
||||
fn send(&mut self, state: &mut State, event: Input) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &[(event, None)]);
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Popup leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn type_text(&mut self, state: &mut State, text: &str) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &typing(text));
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Typing leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn geometry(&mut self, state: &State) -> widget::Geometry {
|
||||
let (_, node) = self.batch(Some(state), &[]);
|
||||
widget::Geometry::from_bounds(node.unwrap().children()[0].bounds())
|
||||
}
|
||||
fn click(&mut self, state: &State, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
Some(state),
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
}
|
||||
|
||||
fn named(key: keyboard::key::Named) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
// Iced 0.14's physical-key path intentionally inserts one scalar from text.
|
||||
// Whole-string typing is several key events; paste and IME commit are separate
|
||||
// input paths and are exercised below rather than faked as one physical key.
|
||||
fn typed(character: char) -> Input {
|
||||
let text = character.to_string();
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.clone().into()),
|
||||
modified_key: keyboard::Key::Character(text.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(text: &str) -> Vec<(Input, Option<Point>)> {
|
||||
text.chars()
|
||||
.map(|character| (typed(character), None))
|
||||
.collect()
|
||||
}
|
||||
fn query(state: &mut State, value: &str) {
|
||||
let event = state.event(Action::Query {
|
||||
text: value.into(),
|
||||
serial: state.query_serial() + 1,
|
||||
});
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
assert_eq!(
|
||||
state.visible(6).map(|c| c.id).collect::<Vec<_>>(),
|
||||
(10_000..10_006).map(Id::from_u128).collect::<Vec<_>>()
|
||||
);
|
||||
query(&mut state, "control subsystem 6 @controller_0617");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617));
|
||||
query(&mut state, "OA");
|
||||
assert_eq!(state.count(), 0);
|
||||
query(&mut state, &Id::from_u128(10_617).to_string());
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().alias, "controller_0617");
|
||||
|
||||
let placement = Candidate {
|
||||
id: Id::from_u128(20_001),
|
||||
name: "Control".into(),
|
||||
alias: "control_placement".into(),
|
||||
kind: "Placement".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Architecture / Main diagram / Control".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![placement],
|
||||
Current::Unassigned,
|
||||
"SA · placements",
|
||||
)
|
||||
.unwrap();
|
||||
query(&mut state, "placement main");
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001));
|
||||
let event = state.event(Action::Choose(Id::from_u128(20_000)));
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Ignored,
|
||||
"Semantic ID is not the placement identity"
|
||||
);
|
||||
assert!(
|
||||
State::open(
|
||||
context(false),
|
||||
vec![candidate(1), candidate(1)],
|
||||
Current::Unassigned,
|
||||
"SA"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() {
|
||||
let state = thousand(Current::InvalidRaw("not a uuid".into()), true);
|
||||
for size in [
|
||||
Size::new(1100., 700.),
|
||||
Size::new(1600., 1000.),
|
||||
Size::new(320., 320.),
|
||||
] {
|
||||
for y in [5., size.height / 2., size.height - 34.] {
|
||||
let mut mount = Mount {
|
||||
size,
|
||||
anchor: Rectangle {
|
||||
x: size.width - 300.,
|
||||
y,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let (_, node) = mount.batch(Some(&state), &[]);
|
||||
let node = node.unwrap();
|
||||
let bounds = node.children()[0].bounds();
|
||||
let geometry = widget::Geometry::from_bounds(bounds);
|
||||
assert!(bounds.x >= 8. && bounds.y >= 8.);
|
||||
assert!(bounds.x + bounds.width <= size.width - 8.);
|
||||
assert!(bounds.y + bounds.height <= size.height - 8.);
|
||||
assert!(bounds.width <= 420. && bounds.height <= 360.);
|
||||
assert!(geometry.rows > 0 && geometry.rows <= 6);
|
||||
assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 });
|
||||
let search = node.children()[1].bounds();
|
||||
assert!(
|
||||
bounds.contains(search.position())
|
||||
&& bounds.contains(Point::new(
|
||||
search.x + search.width,
|
||||
search.y + search.height
|
||||
))
|
||||
);
|
||||
assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y);
|
||||
assert_eq!(state.visible(geometry.rows).count(), geometry.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
2,
|
||||
"Actual focused input must publish each edit"
|
||||
);
|
||||
let mut queries = Vec::new();
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Unexpected parent event")
|
||||
};
|
||||
let Action::Query { text, .. } = &event.action else {
|
||||
panic!("Typing must not choose a reference")
|
||||
};
|
||||
queries.push(text.clone());
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(queries, ["a", "ab"]);
|
||||
assert_eq!(state.query(), "ab");
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Home))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]);
|
||||
assert_eq!(
|
||||
state.query(),
|
||||
"zab",
|
||||
"Rebuilding the controlled widget must not refocus at the end"
|
||||
);
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::PageDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.start(), 6);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006));
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::ArrowDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages.into_iter().next().unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(event.action.writes_staged_value());
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_007))
|
||||
}
|
||||
);
|
||||
assert!(!state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() {
|
||||
let mut state = thousand(Current::Missing(Id::from_u128(99)), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0731";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(state.count(), 1);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_731))
|
||||
}
|
||||
);
|
||||
assert_eq!(state.current, Current::Missing(Id::from_u128(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&state).row(4).center();
|
||||
let captured = mount.click(&state, point);
|
||||
let Message::Chooser(old_choice) = captured[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
query(&mut state, "@controller_0700");
|
||||
assert_eq!(state.update(old_choice), Outcome::Ignored);
|
||||
// The rejected old Choose had marked the widget closing; a changed result
|
||||
// context must unlock it so an explicit current click can complete.
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_700))
|
||||
}
|
||||
);
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
query(&mut state, "@controller_0319");
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() {
|
||||
let source = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&source).row(0).center();
|
||||
let Message::Chooser(event) = mount.click(&source, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
for changed in 0..6 {
|
||||
let mut destination = context(false);
|
||||
match changed {
|
||||
0 => destination.document = Id::from_u128(999),
|
||||
1 => destination.revision += 1,
|
||||
2 => destination.draft = Id::from_u128(999),
|
||||
3 => destination.record = Id::from_u128(999),
|
||||
4 => destination.path = "/body/SomeList/members/1/scenario".into(),
|
||||
_ => {} // A new invocation even with every semantic ID unchanged.
|
||||
}
|
||||
let mut state = State::open(
|
||||
destination,
|
||||
vec![candidate(0)],
|
||||
Current::InvalidRaw("unfinished".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.update(event.clone()), Outcome::Ignored);
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
let mut invalidated = source.clone();
|
||||
invalidated.invalidate();
|
||||
assert_eq!(invalidated.update(event), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() {
|
||||
for current in [
|
||||
Current::Missing(Id::from_u128(999)),
|
||||
Current::InvalidRaw("@incomplete/".into()),
|
||||
Current::Incompatible(candidate(999)),
|
||||
] {
|
||||
let mut state = thousand(current.clone(), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "no match exists";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(
|
||||
state.count(),
|
||||
0,
|
||||
"Verify actual input reached the empty-result state before Enter"
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Enter))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
assert_eq!(state.current, current);
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Escape)),
|
||||
[Outcome::Closed(Close::Escape)]
|
||||
);
|
||||
assert_eq!(state.current, current);
|
||||
}
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Tab)),
|
||||
[Outcome::Closed(Close::Tab { backwards: false })]
|
||||
);
|
||||
let mut state = thousand(Current::InvalidRaw("?".into()), true);
|
||||
let point = mount.geometry(&state).clear.center();
|
||||
let Message::Chooser(event) = mount.click(&state, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(true),
|
||||
value: None
|
||||
}
|
||||
);
|
||||
let mut required = thousand(Current::Unassigned, false);
|
||||
let clear = required.event(Action::Clear);
|
||||
assert_eq!(required.update(clear), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let outside = Point::new(20., 20.);
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside),
|
||||
)],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Expected only popup dismissal")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside));
|
||||
let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
|
||||
assert!(
|
||||
mount
|
||||
.batch(None, &[(release.clone(), Some(outside))])
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
mount
|
||||
.batch(
|
||||
None,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside)
|
||||
),
|
||||
(release, Some(outside)),
|
||||
]
|
||||
)
|
||||
.0,
|
||||
[Message::UnderPress, Message::UnderRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() {
|
||||
for path in [
|
||||
"/baseline",
|
||||
"/baseline/oa/value",
|
||||
"/baseline/sa/value/TypeRef/Named",
|
||||
] {
|
||||
assert!(
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
0,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
path,
|
||||
true
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut forged = context(true);
|
||||
forged.path = path.into();
|
||||
assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err());
|
||||
}
|
||||
for action in [
|
||||
Action::Query {
|
||||
text: "Control".into(),
|
||||
serial: 1,
|
||||
},
|
||||
Action::Highlight(Id::from_u128(10_000)),
|
||||
Action::Move { delta: 1, rows: 6 },
|
||||
Action::Page { delta: 1, rows: 6 },
|
||||
Action::Close(Close::Escape),
|
||||
] {
|
||||
assert!(!action.writes_staged_value());
|
||||
}
|
||||
assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value());
|
||||
assert!(Action::Clear.writes_staged_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_query_serials_cannot_restore_obsolete_results() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old = state.event(Action::Query {
|
||||
text: "@controller_0001".into(),
|
||||
serial: 1,
|
||||
});
|
||||
let new = state.event(Action::Query {
|
||||
text: "@controller_0999".into(),
|
||||
serial: 2,
|
||||
});
|
||||
assert_eq!(state.update(new), Outcome::Browsed);
|
||||
assert_eq!(state.update(old), Outcome::Ignored);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() {
|
||||
let mut state = thousand(Current::InvalidRaw("unfinished".into()), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0720";
|
||||
let mut inputs = typing(query);
|
||||
inputs.push((named(keyboard::key::Named::Escape), None));
|
||||
let (messages, _) = mount.batch(Some(&state), &inputs);
|
||||
let outcomes: Vec<_> = messages
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
state.update(event)
|
||||
})
|
||||
.collect();
|
||||
let mut expected = vec![Outcome::Browsed; query.chars().count()];
|
||||
expected.push(Outcome::Closed(Close::Escape));
|
||||
assert_eq!(outcomes, expected);
|
||||
assert_eq!(state.query(), "@controller_0720");
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000)));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::ArrowUp), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 3);
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011));
|
||||
assert_eq!(state.start(), 11);
|
||||
assert_eq!(state.update(old_absolute_row), Outcome::Ignored);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
mount.clipboard.0 = Some("@controller_0472".into());
|
||||
let paste = Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character("v".into()),
|
||||
modified_key: keyboard::Key::Character("v".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::COMMAND,
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::COMMAND,
|
||||
)),
|
||||
None,
|
||||
),
|
||||
(paste, None),
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::empty(),
|
||||
)),
|
||||
None,
|
||||
),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Paste should emit exactly one whole-string query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), "@controller_0472");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472));
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
assert!(state.is_open());
|
||||
|
||||
let unicode_name = "Режим Δ負荷";
|
||||
let unicode = Candidate {
|
||||
id: Id::from_u128(30_111),
|
||||
name: unicode_name.into(),
|
||||
alias: "mode_unicode".into(),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Система / 控制".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![unicode],
|
||||
Current::InvalidRaw("retained".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
use iced::advanced::input_method::Event as Ime;
|
||||
let preedit = [
|
||||
(Input::InputMethod(Ime::Opened), None),
|
||||
(
|
||||
Input::InputMethod(Ime::Preedit(unicode_name.into(), None)),
|
||||
None,
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
mount.batch(Some(&state), &preedit).0.is_empty(),
|
||||
"Composition is not a committed query"
|
||||
);
|
||||
assert_eq!(state.query(), "");
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(Input::InputMethod(Ime::Preedit(String::new(), None)), None),
|
||||
(Input::InputMethod(Ime::Commit(unicode_name.into())), None),
|
||||
(Input::InputMethod(Ime::Closed), None),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("IME commit should emit one complete query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), unicode_name);
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111));
|
||||
assert_eq!(state.current, Current::InvalidRaw("retained".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
//! Keyboard entry and focus operations scoped to one property form. Focus
|
||||
//! targets include the draft generation so delayed operations cannot focus a
|
||||
//! replacement array row or a recovered document with the same semantic IDs.
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Length, Rectangle, Size, Task, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, operation, tree},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::container,
|
||||
};
|
||||
use oa_core::Id as ModelId;
|
||||
mod focus;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FormScope {
|
||||
pub session: ModelId,
|
||||
pub modal: bool,
|
||||
}
|
||||
pub(super) struct FormOwner(pub Option<FormScope>);
|
||||
const REVIEW_MODAL: &str = "reference-owning-review-modal";
|
||||
|
||||
/// Marks the entire Review dialog without changing its layout or event flow.
|
||||
pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Field {
|
||||
content: content.into(),
|
||||
id: Some(Id::new(REVIEW_MODAL)),
|
||||
on_open: None,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn field_id(generation: ModelId, path: &str) -> Id {
|
||||
Id::from(format!("reference:{generation}:{path}"))
|
||||
}
|
||||
pub fn scope_id(session: ModelId) -> Id {
|
||||
Id::from(format!("reference-form:{session}"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
identity: Option<Id>,
|
||||
anchor: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Interaction {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
}
|
||||
pub struct Field<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
id: Option<Id>,
|
||||
on_open: Option<Box<dyn Fn(Rectangle) -> Message + 'a>>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Field<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
id: Id,
|
||||
on_open: impl Fn(Rectangle) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
id: Some(id),
|
||||
on_open: Some(Box::new(on_open)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn scope<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
session: ModelId,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
container(Field {
|
||||
content: content.into(),
|
||||
id: None,
|
||||
on_open: None,
|
||||
})
|
||||
.id(scope_id(session))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn run<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
mut operation: Box<dyn Operation>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
loop {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => {
|
||||
operation = next;
|
||||
changed = true;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn tab<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
scope: FormScope,
|
||||
backwards: bool,
|
||||
) -> bool {
|
||||
run(
|
||||
content,
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
Box::new(focus::relative(scope.session, backwards, scope.modal)),
|
||||
)
|
||||
}
|
||||
pub(super) fn own_input<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
) {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut focus::exclusive());
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction {
|
||||
identity: self.id.clone(),
|
||||
..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::<Interaction>();
|
||||
if state.identity != self.id {
|
||||
*state = Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
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,
|
||||
) {
|
||||
if let Some(id) = &self.id
|
||||
&& self.on_open.is_some()
|
||||
{
|
||||
operation.focusable(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
operation.custom(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
}
|
||||
if self.on_open.is_none() {
|
||||
operation.container(self.id.as_ref(), layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
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 let Some(on_open) = &self.on_open {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds));
|
||||
let activate = match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if hovered => {
|
||||
state.focused = true;
|
||||
state.pressed = true;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
false
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let activate = state.pressed && hovered;
|
||||
if state.pressed {
|
||||
shell.capture_event();
|
||||
}
|
||||
state.pressed = false;
|
||||
activate
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key:
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space),
|
||||
repeat: false,
|
||||
..
|
||||
}) if state.focused => true,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
state.focused = false;
|
||||
state.pressed = false;
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if activate && let Some(bounds) = state.anchor {
|
||||
shell.publish(on_open(bounds));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
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,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.on_open.is_some() {
|
||||
let focus = tree.state.downcast_ref::<Interaction>().focused;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: if focus || cursor.is_over(layout.bounds()) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
},
|
||||
width: if focus { 2. } else { 1. },
|
||||
radius: 5.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
}
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.on_open.is_some() && cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable child updates use content coordinates. The root visits the
|
||||
/// retained tree before each input, so field anchors use current window
|
||||
/// coordinates even when a wheel event and activation share one runtime batch.
|
||||
pub(super) fn measure<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
viewport: Rectangle,
|
||||
) {
|
||||
content.as_widget_mut().operate(
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
&mut Measure {
|
||||
translation: Vector::ZERO,
|
||||
viewport: Some(viewport),
|
||||
pending: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
struct Measure {
|
||||
translation: Vector,
|
||||
viewport: Option<Rectangle>,
|
||||
pending: Option<(Vector, Option<Rectangle>)>,
|
||||
}
|
||||
impl Operation for Measure {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.viewport);
|
||||
if let Some((translation, viewport)) = self.pending.take() {
|
||||
self.translation = translation;
|
||||
self.viewport = viewport;
|
||||
}
|
||||
visit(self);
|
||||
(self.translation, self.viewport) = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
self.pending = Some((
|
||||
self.translation + translation,
|
||||
self.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds)),
|
||||
));
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, bounds: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(state) = state.downcast_mut::<Interaction>() {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
state.anchor = self
|
||||
.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Field<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(field: Field<'a, Message, R>) -> Self {
|
||||
Element::new(field)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_operation(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> impl Operation + 'static {
|
||||
focus::exact(session, generation, path, step)
|
||||
}
|
||||
pub fn focus<Message: Send + 'static>(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> Task<Message> {
|
||||
iced::advanced::widget::operate(focus_operation(session, generation, path, step)).discard()
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
//! Focus movement and reveal share one root traversal. A form may be inside a
|
||||
//! scrollable; restricting traversal to its container would hide that ancestor.
|
||||
use super::{FormOwner, Id, ModelId, Operation, Rectangle, Vector, field_id, operation, scope_id};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
enum Origin {
|
||||
Current,
|
||||
Exact(Id),
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Field {
|
||||
id: Option<Id>,
|
||||
bounds: Rectangle,
|
||||
ancestors: Vec<Scroll>,
|
||||
}
|
||||
pub(super) struct Collect {
|
||||
scope: Id,
|
||||
origin: Origin,
|
||||
step: i32,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending_scroll: Option<Scroll>,
|
||||
scroll_index: usize,
|
||||
fields: Vec<Field>,
|
||||
focused: Option<usize>,
|
||||
outside_focused: bool,
|
||||
modal: bool,
|
||||
owner_valid: bool,
|
||||
}
|
||||
impl Collect {
|
||||
fn new(scope: Id, origin: Origin, step: i32) -> Self {
|
||||
let owner_valid = matches!(origin, Origin::Current);
|
||||
Self {
|
||||
scope,
|
||||
origin,
|
||||
step,
|
||||
inside: false,
|
||||
entering: None,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending_scroll: None,
|
||||
scroll_index: 0,
|
||||
fields: vec![],
|
||||
focused: None,
|
||||
outside_focused: false,
|
||||
modal: false,
|
||||
owner_valid,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn relative(session: ModelId, backwards: bool, modal: bool) -> Collect {
|
||||
Collect {
|
||||
modal,
|
||||
..Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Current,
|
||||
if backwards { -1 } else { 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
pub(super) fn exact(session: ModelId, generation: ModelId, path: &str, step: i32) -> Collect {
|
||||
Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Exact(field_id(generation, path)),
|
||||
step,
|
||||
)
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.modal = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.inside, self.translation, self.scrolls.len());
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
if let Some(scroll) = self.pending_scroll.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous.0;
|
||||
self.translation = previous.1;
|
||||
self.scrolls.truncate(previous.2);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending_scroll = Some(Scroll {
|
||||
index: self.scroll_index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if !self.inside {
|
||||
self.outside_focused |= state.is_focused();
|
||||
return;
|
||||
}
|
||||
if state.is_focused() {
|
||||
self.focused = Some(self.fields.len());
|
||||
}
|
||||
self.fields.push(Field {
|
||||
id: id.cloned(),
|
||||
bounds: translated(bounds, self.translation),
|
||||
ancestors: self.scrolls.clone(),
|
||||
});
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.owner_valid || self.fields.is_empty() {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let origin = match &self.origin {
|
||||
Origin::Current if self.focused.is_none() && self.outside_focused && !self.modal => {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
Origin::Current => self
|
||||
.focused
|
||||
.map(|index| index as i64)
|
||||
.unwrap_or(if self.step < 0 {
|
||||
self.fields.len() as i64
|
||||
} else {
|
||||
-1
|
||||
}),
|
||||
Origin::Exact(id) => {
|
||||
let Some(index) = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.id.as_ref() == Some(id))
|
||||
else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
index as i64
|
||||
}
|
||||
};
|
||||
// A form keeps a reachable focus at both boundaries. Initial Tab enters
|
||||
// the first field; initial Shift+Tab enters the last field.
|
||||
let target = (origin + i64::from(self.step)).rem_euclid(self.fields.len() as i64) as usize;
|
||||
let field = &self.fields[target];
|
||||
let mut bounds = field.bounds;
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in field.ancestors.iter().rev() {
|
||||
let dx = reveal_axis(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal_axis(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
// Iced reports rounded drawing offsets, while capacity can be
|
||||
// fractional. Clamping an already-visible axis can move it a pixel.
|
||||
let delta = Vector::new(
|
||||
if dx == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
|
||||
},
|
||||
if dy == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
|
||||
},
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope.clone(),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts,
|
||||
owner_valid: matches!(self.origin, Origin::Current),
|
||||
clear_outside: self.modal,
|
||||
preserve_inside: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal_axis(start: f32, length: f32, viewport_start: f32, viewport_length: f32) -> f32 {
|
||||
if start < viewport_start || length > viewport_length {
|
||||
start - viewport_start
|
||||
} else {
|
||||
(start + length - viewport_start - viewport_length).max(0.)
|
||||
}
|
||||
}
|
||||
pub(super) struct Apply {
|
||||
scope: Id,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
target: usize,
|
||||
field_index: usize,
|
||||
scroll_index: usize,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
owner_valid: bool,
|
||||
clear_outside: bool,
|
||||
preserve_inside: bool,
|
||||
}
|
||||
pub(super) fn exclusive() -> Apply {
|
||||
Apply {
|
||||
scope: Id::new(super::REVIEW_MODAL),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target: 0,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts: BTreeMap::new(),
|
||||
owner_valid: true,
|
||||
clear_outside: true,
|
||||
preserve_inside: true,
|
||||
}
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.clear_outside = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = self.inside;
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.owner_valid
|
||||
&& let Some(delta) = self.shifts.get(&self.scroll_index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.owner_valid && self.inside && !self.preserve_inside {
|
||||
if self.field_index == self.target {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
self.field_index += 1;
|
||||
} else if self.owner_valid && !self.inside && self.clear_outside {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,994 +0,0 @@
|
||||
//! Root overlay adapter. Keep the wrapper mounted while closed so the release
|
||||
//! from a dismissal gesture cannot reach the underlying content. Parent modal
|
||||
//! ownership is supplied by the host (pass no chooser while a modal is active).
|
||||
use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser};
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
text::{self, Paragraph as _},
|
||||
widget::{Operation, Tree, tree},
|
||||
},
|
||||
alignment, keyboard, mouse, touch,
|
||||
widget::text_input,
|
||||
window,
|
||||
};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
const ROW: f32 = 34.;
|
||||
const MARGIN: f32 = 8.;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Geometry {
|
||||
pub popup: Rectangle,
|
||||
pub search: Rectangle,
|
||||
pub current: Rectangle,
|
||||
pub detail: Rectangle,
|
||||
pub close: Rectangle,
|
||||
pub previous: Rectangle,
|
||||
pub next: Rectangle,
|
||||
pub clear: Rectangle,
|
||||
pub rows: usize,
|
||||
}
|
||||
impl Geometry {
|
||||
fn new(anchor: Rectangle, viewport: Size) -> Self {
|
||||
let width = anchor
|
||||
.width
|
||||
.clamp(278., 420.)
|
||||
.min((viewport.width - 2. * MARGIN).max(0.));
|
||||
let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.));
|
||||
let x = anchor.x.clamp(
|
||||
MARGIN.min(viewport.width / 2.),
|
||||
(viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)),
|
||||
);
|
||||
let below = anchor.y + anchor.height + 4.;
|
||||
let preferred = if below + height + MARGIN <= viewport.height {
|
||||
below
|
||||
} else {
|
||||
anchor.y - height - 4.
|
||||
};
|
||||
let y = preferred.clamp(
|
||||
MARGIN.min(viewport.height / 2.),
|
||||
(viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)),
|
||||
);
|
||||
Self::from_bounds(Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub(super) fn from_bounds(popup: Rectangle) -> Self {
|
||||
let inner = (popup.width - 16.).max(0.);
|
||||
let rect = |left, top, width, height| Rectangle {
|
||||
x: popup.x + left,
|
||||
y: popup.y + top,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let footer = (popup.height - 32.).max(0.);
|
||||
let detail_height = if popup.height >= 180. { 32. } else { 0. };
|
||||
let detail_top = (footer - detail_height - 4.).max(84.);
|
||||
let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS);
|
||||
Self {
|
||||
popup,
|
||||
search: rect(8., 28., inner, 30.),
|
||||
current: rect(8., 62., inner, 18.),
|
||||
detail: rect(8., detail_top, inner, detail_height),
|
||||
close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.),
|
||||
previous: rect(8., footer, 28., 24.),
|
||||
next: rect(40., footer, 28., 24.),
|
||||
clear: rect((popup.width - 84.).max(0.), footer, 76., 24.),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
pub fn row(&self, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: self.popup.x + 8.,
|
||||
y: self.popup.y + 84. + index as f32 * ROW,
|
||||
width: (self.popup.width - 16.).max(0.),
|
||||
height: ROW,
|
||||
}
|
||||
}
|
||||
fn hit(&self, chooser: &Chooser, point: Point) -> Option<Action> {
|
||||
if self.close.contains(point) {
|
||||
return Some(Action::Close(Close::Escape));
|
||||
}
|
||||
if self.previous.contains(point) && chooser.start() > 0 {
|
||||
return Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.next.contains(point) && chooser.start() + self.rows < chooser.count() {
|
||||
return Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.clear.contains(point) && chooser.optional() {
|
||||
return Some(Action::Clear);
|
||||
}
|
||||
chooser
|
||||
.visible(self.rows)
|
||||
.enumerate()
|
||||
.find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
stamp: Option<Stamp>,
|
||||
serial: u64,
|
||||
pressed: Option<(ChoiceEvent, Pointer)>,
|
||||
closing: bool,
|
||||
swallow_mouse: Option<mouse::Button>,
|
||||
swallow_touch: Option<touch::Finger>,
|
||||
held_mouse: Option<mouse::Button>,
|
||||
held_touch: Option<touch::Finger>,
|
||||
detail_scroll: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Interaction {
|
||||
fn swallow(&mut self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.swallow_mouse == Some(*button) =>
|
||||
{
|
||||
self.swallow_mouse = None;
|
||||
true
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.swallow_touch == Some(*id) => {
|
||||
self.swallow_touch = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: text::Renderer<Font = Font>,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
input: Element<'a, String, Theme, R>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: Rc<dyn Fn(ChoiceEvent) -> Message + 'a>,
|
||||
focus_scope: Option<super::trigger::FormScope>,
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> Popup<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: impl Fn(ChoiceEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
let chooser = chooser.filter(|state| state.is_open());
|
||||
let input = text_input(
|
||||
"Name, @alias, kind or containment",
|
||||
chooser.map_or("", Chooser::query),
|
||||
)
|
||||
.on_input(std::convert::identity)
|
||||
.size(12)
|
||||
.padding(6)
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
Self {
|
||||
content: content.into(),
|
||||
input,
|
||||
chooser,
|
||||
anchor,
|
||||
on_event: Rc::new(on_event),
|
||||
focus_scope: None,
|
||||
}
|
||||
}
|
||||
pub fn focus_scope(mut self, scope: Option<super::trigger::FormScope>) -> Self {
|
||||
self.focus_scope = scope;
|
||||
self
|
||||
}
|
||||
fn reconcile(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let stamp = self.chooser.map(Chooser::stamp);
|
||||
if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) {
|
||||
let input = tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<R::Paragraph>>();
|
||||
if stamp.is_some() {
|
||||
input.focus();
|
||||
} else {
|
||||
input.unfocus();
|
||||
}
|
||||
state.closing = false;
|
||||
state.serial = self.chooser.map_or(0, Chooser::query_serial);
|
||||
}
|
||||
if state.stamp != stamp {
|
||||
state.pressed = None;
|
||||
// A queued Choose can be rejected after a preceding query changed
|
||||
// the result generation. Reconciliation must unlock this view.
|
||||
state.closing = false;
|
||||
state.detail_scroll = 0.;
|
||||
}
|
||||
state.stamp = stamp;
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> Widget<Message, Theme, R>
|
||||
for Popup<'_, Message, R>
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![
|
||||
Tree::new(self.content.as_widget()),
|
||||
Tree::new(self.input.as_widget()),
|
||||
]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.children[0].diff(self.content.as_widget());
|
||||
tree.children[1].diff(self.input.as_widget());
|
||||
self.reconcile(tree);
|
||||
}
|
||||
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.reconcile(tree);
|
||||
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 mut owner =
|
||||
super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none()));
|
||||
operation.custom(None, layout.bounds(), &mut owner);
|
||||
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,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.swallow(event) || self.chooser.is_some() && is_input(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) {
|
||||
super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer);
|
||||
}
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Tab),
|
||||
modifiers,
|
||||
..
|
||||
}) = event
|
||||
&& let Some(scope) = self.focus_scope
|
||||
&& super::trigger::tab(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
scope,
|
||||
modifiers.shift(),
|
||||
)
|
||||
{
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
super::trigger::measure(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
*viewport,
|
||||
);
|
||||
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,
|
||||
if self.chooser.is_some() {
|
||||
mouse::Cursor::Unavailable
|
||||
} else {
|
||||
cursor
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.chooser.is_some() {
|
||||
mouse::Interaction::None
|
||||
} else {
|
||||
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>> {
|
||||
if let Some(chooser) = self.chooser {
|
||||
Some(overlay::Element::new(Box::new(ChooserOverlay {
|
||||
chooser,
|
||||
anchor: self.anchor,
|
||||
state: tree.state.downcast_mut(),
|
||||
input: &mut self.input,
|
||||
input_tree: &mut tree.children[1],
|
||||
on_event: &*self.on_event,
|
||||
})))
|
||||
} else {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> From<Popup<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(popup: Popup<'a, Message, R>) -> Self {
|
||||
Element::new(popup)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooserOverlay<'b, 'a, Message, R: text::Renderer<Font = Font>> {
|
||||
chooser: &'a Chooser,
|
||||
anchor: Rectangle,
|
||||
state: &'b mut Interaction,
|
||||
input: &'b mut Element<'a, String, Theme, R>,
|
||||
input_tree: &'b mut Tree,
|
||||
on_event: &'b dyn Fn(ChoiceEvent) -> Message,
|
||||
}
|
||||
impl<Message, R: text::Renderer<Font = Font>> ChooserOverlay<'_, '_, Message, R> {
|
||||
fn geometry(&self, layout: Layout<'_>) -> Geometry {
|
||||
Geometry::from_bounds(
|
||||
layout
|
||||
.children()
|
||||
.next()
|
||||
.expect("measured chooser bounds")
|
||||
.bounds(),
|
||||
)
|
||||
}
|
||||
fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) {
|
||||
if self.state.closing {
|
||||
return;
|
||||
}
|
||||
if action.writes_staged_value() || matches!(action, Action::Close(_)) {
|
||||
self.state.closing = true;
|
||||
self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse);
|
||||
self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch);
|
||||
}
|
||||
shell.publish((self.on_event)(self.chooser.event(action)));
|
||||
}
|
||||
fn press(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.publish(Action::Close(Close::Outside), shell);
|
||||
} else {
|
||||
self.state.pressed = geometry
|
||||
.hit(self.chooser, point)
|
||||
.map(|action| (self.chooser.event(action), pointer));
|
||||
}
|
||||
}
|
||||
fn release(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if let Some((pressed, origin)) = self.state.pressed.take()
|
||||
&& origin == pointer
|
||||
&& pressed.stamp == self.chooser.stamp()
|
||||
&& geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action)
|
||||
{
|
||||
self.publish(pressed.action, shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, Theme, R>
|
||||
for ChooserOverlay<'_, '_, Message, R>
|
||||
{
|
||||
fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node {
|
||||
let geometry = Geometry::new(self.anchor, bounds);
|
||||
let input = self
|
||||
.input
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
&layout::Limits::new(Size::ZERO, geometry.search.size()),
|
||||
)
|
||||
.move_to(geometry.search.position());
|
||||
layout::Node::with_children(
|
||||
bounds,
|
||||
vec![
|
||||
layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()),
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) {
|
||||
self.input.as_widget_mut().operate(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
self.state.held_mouse = Some(*button)
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.state.held_mouse == Some(*button) =>
|
||||
{
|
||||
self.state.held_mouse = None
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
|
||||
self.state.held_touch = Some(*id)
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.state.held_touch == Some(*id) => self.state.held_touch = None,
|
||||
_ => {}
|
||||
}
|
||||
if self.state.swallow(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.state.closing {
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut handled = false;
|
||||
match event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
|
||||
use keyboard::key::Named;
|
||||
let action = match key {
|
||||
keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)),
|
||||
keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab {
|
||||
backwards: modifiers.shift(),
|
||||
})),
|
||||
keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageDown) => Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageUp) => Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::Enter) => {
|
||||
handled = true;
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id))
|
||||
.map(|c| Action::Choose(c.id))
|
||||
}
|
||||
keyboard::Key::Character(c)
|
||||
if modifiers.control()
|
||||
&& modifiers.shift()
|
||||
&& c.eq_ignore_ascii_case("c") =>
|
||||
{
|
||||
clipboard.write(
|
||||
iced::advanced::clipboard::Kind::Standard,
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail()),
|
||||
);
|
||||
handled = true;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
self.publish(action, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => {
|
||||
self.publish(Action::Close(Close::WindowChanged), shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if let Some(point) = cursor.position() {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.state.swallow_mouse = Some(*button);
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
} else if *button == mouse::Button::Left && !geometry.search.contains(point) {
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
if let Some(point) = cursor.position() {
|
||||
self.release(point, Pointer::Mouse, geometry, shell);
|
||||
} else {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position })
|
||||
if self.state.pressed.is_none() =>
|
||||
{
|
||||
if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position)
|
||||
&& self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_none_or(|candidate| candidate.id != id)
|
||||
{
|
||||
self.publish(Action::Highlight(id), shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
|
||||
let (x, y) = match delta {
|
||||
mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.),
|
||||
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
|
||||
};
|
||||
self.state.pressed = None;
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.detail.contains(p))
|
||||
{
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
let max = (measure::<R>(&detail, 11., renderer.default_font())
|
||||
- geometry.detail.width)
|
||||
.max(0.);
|
||||
self.state.detail_scroll =
|
||||
(self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max);
|
||||
shell.request_redraw();
|
||||
} else if y != 0. {
|
||||
self.publish(
|
||||
Action::Page {
|
||||
delta: if y < 0. { 1 } else { -1 },
|
||||
rows: geometry.rows,
|
||||
},
|
||||
shell,
|
||||
);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, position })
|
||||
if !geometry.search.contains(*position) =>
|
||||
{
|
||||
if !geometry.popup.contains(*position) {
|
||||
self.state.swallow_touch = Some(*id);
|
||||
}
|
||||
self.press(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLifted { id, position })
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
self.release(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !handled {
|
||||
let mut messages = Vec::new();
|
||||
let mut input_shell = Shell::new(&mut messages);
|
||||
self.input.as_widget_mut().update(
|
||||
self.input_tree,
|
||||
event,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut input_shell,
|
||||
&geometry.popup,
|
||||
);
|
||||
let serial = Cell::new(self.state.serial);
|
||||
shell.merge(input_shell, |text| {
|
||||
serial.set(serial.get().saturating_add(1));
|
||||
(self.on_event)(self.chooser.event(Action::Query {
|
||||
text,
|
||||
serial: serial.get(),
|
||||
}))
|
||||
});
|
||||
self.state.serial = serial.get();
|
||||
}
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: geometry.popup,
|
||||
border: Border {
|
||||
color: style::LINE,
|
||||
width: 1.,
|
||||
radius: 8.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
renderer.with_layer(geometry.popup, |renderer| {
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.scope_label,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.popup.x + 8.,
|
||||
y: geometry.popup.y + 4.,
|
||||
width: (geometry.popup.width - 78.).max(0.),
|
||||
height: 22.,
|
||||
},
|
||||
);
|
||||
label(renderer, "Close", 11., style::TEAL, geometry.close);
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.current.label(),
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.current,
|
||||
);
|
||||
for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() {
|
||||
let row = geometry.row(i);
|
||||
if self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_some_and(|c| c.id == candidate.id)
|
||||
{
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: row,
|
||||
..Default::default()
|
||||
},
|
||||
Color::from_rgb8(218, 238, 235),
|
||||
);
|
||||
}
|
||||
let alias_width = (row.width * 0.42).min(
|
||||
measure::<R>(
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
renderer.default_font(),
|
||||
) + 4.,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.name,
|
||||
12.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
width: (row.width - alias_width - 6.).max(0.),
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
x: row.x + row.width - alias_width,
|
||||
width: alias_width,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.context_label(),
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: row.y + 17.,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.chooser.count() == 0 {
|
||||
label(
|
||||
renderer,
|
||||
"No compatible matches. Clear the query.",
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.row(0),
|
||||
);
|
||||
}
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
renderer.with_layer(geometry.detail, |renderer| {
|
||||
raw_label(
|
||||
renderer,
|
||||
detail,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.detail.x - self.state.detail_scroll,
|
||||
width: geometry.detail.width + self.state.detail_scroll,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"Scroll details · Ctrl+Shift+C copies",
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: geometry.detail.y + 16.,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
});
|
||||
label(
|
||||
renderer,
|
||||
"‹",
|
||||
15.,
|
||||
if self.chooser.start() > 0 {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.previous,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"›",
|
||||
15.,
|
||||
if self.chooser.start() + geometry.rows < self.chooser.count() {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.next,
|
||||
);
|
||||
let count = if self.chooser.count() == 0 {
|
||||
"0 results".into()
|
||||
} else {
|
||||
format!(
|
||||
"{}–{} / {}",
|
||||
self.chooser.start() + 1,
|
||||
(self.chooser.start() + geometry.rows).min(self.chooser.count()),
|
||||
self.chooser.count()
|
||||
)
|
||||
};
|
||||
label(
|
||||
renderer,
|
||||
&count,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.next.x + 30.,
|
||||
width: (geometry.clear.x - geometry.next.x - 32.).max(0.),
|
||||
..geometry.next
|
||||
},
|
||||
);
|
||||
if self.chooser.optional() {
|
||||
label(
|
||||
renderer,
|
||||
"Clear reference",
|
||||
10.,
|
||||
style::TEAL,
|
||||
geometry.clear,
|
||||
);
|
||||
}
|
||||
self.input.as_widget().draw(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
let geometry = self.geometry(layout);
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.hit(self.chooser, p).is_some())
|
||||
{
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_input(event: &Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
|
||||
)
|
||||
}
|
||||
fn measure<R: text::Renderer<Font = Font>>(content: &str, size: f32, font: Font) -> f32 {
|
||||
R::Paragraph::with_text(text::Text {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font,
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
})
|
||||
.min_width()
|
||||
}
|
||||
fn label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: &str,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let font = renderer.default_font();
|
||||
let mut display = content.to_owned();
|
||||
if measure::<R>(content, size, font) > bounds.width {
|
||||
let ends: Vec<_> = content
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(content.len()))
|
||||
.collect();
|
||||
let (mut low, mut high) = (0, ends.len());
|
||||
while low + 1 < high {
|
||||
let middle = (low + high) / 2;
|
||||
if measure::<R>(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width
|
||||
{
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
display = format!("{}…", &content[..ends[low]]);
|
||||
}
|
||||
raw_label(renderer, display, size, color, bounds);
|
||||
}
|
||||
fn raw_label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: String,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content,
|
||||
bounds: bounds.size(),
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
},
|
||||
Point::new(bounds.x, bounds.center_y()),
|
||||
color,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
@@ -445,32 +445,28 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"On an empty path, choose Work (W) from the right palette and click the canvas. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
|
||||
"Choose Work (W) or Exchange (E) from the permanent palette on the right, then a highlighted position. Hover a palette button to read its tool name. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"Choose Sequence link (->) and click or drag from the preceding contribution to the following contribution. Choose Select contributions or paths (S) to drag a selected path endpoint and reconnect it. A contribution's ↕ handle reorders contributions within the same column; links determine the columns. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Details → Two paths... creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions.",
|
||||
"Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Navigate the canvas",
|
||||
"The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.",
|
||||
),
|
||||
explain(
|
||||
"Choose the right behavioral description",
|
||||
"A chain concentrates on a route through existing exchanges. A scenario can also carry actions, state observations, lifelines and nested fragments. The architecture diagram remains a view of the possible relationships rather than an execution trace.",
|
||||
),
|
||||
explain(
|
||||
"Review the path as an engineer",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse process / chain (ref) in the palette to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Details → Append exchange... continues that path; Edit path links and guards exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Tools → Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
),
|
||||
walkthrough(
|
||||
"Connect a particular use of a procedure",
|
||||
&[
|
||||
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Details to read its complete caller and reference identity.",
|
||||
"In Details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
|
||||
"Open Details → Qualified link / assembly... and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
|
||||
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Selection details to read its complete caller and reference identity.",
|
||||
"In Selection details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
|
||||
"Open Tools → Qualified link / assembly… and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
|
||||
"Choose Precedence to describe order and its guard, Exchange assembly to identify a work exchange between the contributions, or Common work incidence to state that both use the same work. Select the engineering identity when required, then Apply link.",
|
||||
],
|
||||
),
|
||||
@@ -494,23 +490,19 @@ fn article(topic: Topic) -> Article {
|
||||
"Create a scenario",
|
||||
&[
|
||||
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
|
||||
"Choose Lifeline (L) in the permanent palette on the right, then click the canvas. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
|
||||
"Choose a message style from the palette; hover a button to read its tool name. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
|
||||
"Call and correlated reply (C/R) creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose Action (A), State observation (St), Reuse scenario (ref) or Fragment (alt) and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
|
||||
"Choose Select / reorder occurrences (S) and drag an occurrence itself to another position within the same sequence. A drag into another fragment operand is refused. Open Details for the ordered list's up/down controls or Properties for the selected occurrence, including its message values.",
|
||||
"Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
|
||||
"Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
|
||||
"Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
|
||||
"In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Navigate the canvas",
|
||||
"The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.",
|
||||
),
|
||||
walkthrough(
|
||||
"Describe alternatives",
|
||||
&[
|
||||
"Choose Fragment (alt), click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
|
||||
"Choose Add fragment, click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
|
||||
"Click the highlighted position inside an operand to place the next message, action or reference there. The form shows its destination and insertion position. Append to this sequence instead explicitly moves the pending insertion to that sequence's end.",
|
||||
"Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Details → Open definition. Back returns to that exact use and its viewport after you apply or cancel pending edits.",
|
||||
"Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Open definition. Back to caller returns to that exact use and its viewport after you apply or cancel pending edits.",
|
||||
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -87,9 +87,6 @@ pub enum RowEvent {
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
ValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
ReferenceBrowse(String, String, usize),
|
||||
ReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
ReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
ReferenceSet(crate::forms::reference_chooser::adapter::Set),
|
||||
OperationOmission(bool),
|
||||
OperationRationale(String),
|
||||
Tab(Tab),
|
||||
|
||||
@@ -296,51 +296,6 @@ impl State {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn reference_form(&self, project: &Project) -> Option<&Draft> {
|
||||
let facet = self.active.selected.as_ref()?;
|
||||
if self.frozen(project).is_err()
|
||||
|| self.active.tab != Tab::Facts
|
||||
|| self.active.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.active.candidate_draft(facet)
|
||||
}
|
||||
pub(crate) fn reference_draft(
|
||||
&self,
|
||||
project: &Project,
|
||||
address: Option<RowAddress>,
|
||||
facet: &FacetSelector,
|
||||
path: &str,
|
||||
) -> Option<&Draft> {
|
||||
if self.frozen(project).is_err()
|
||||
|| self.active_address != address
|
||||
|| self.active.tab != Tab::Facts
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.active.reference_draft(project, facet, path)
|
||||
}
|
||||
pub(crate) fn set_reference(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
address: Option<RowAddress>,
|
||||
facet: &FacetSelector,
|
||||
intent: crate::forms::reference_chooser::adapter::Set,
|
||||
) -> Result<(), String> {
|
||||
self.reference_draft(project, address, facet, &intent.context.path)
|
||||
.ok_or("The originating review row or property is no longer active.")?
|
||||
.reference_value(project, &intent)?;
|
||||
self.update(
|
||||
project,
|
||||
Event::Row {
|
||||
address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceSet(intent),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(&mut self, project: &Project, event: impl Into<Event>) {
|
||||
let event = event.into();
|
||||
#[cfg(test)]
|
||||
@@ -348,30 +303,6 @@ impl State {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
match &event {
|
||||
Event::Row {
|
||||
event: RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_),
|
||||
..
|
||||
}
|
||||
| Event::Editor(RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_)) => return,
|
||||
Event::Row {
|
||||
address,
|
||||
facet,
|
||||
event: RowEvent::ReferenceSet(intent),
|
||||
} => {
|
||||
let valid = facet
|
||||
.as_ref()
|
||||
.and_then(|facet| {
|
||||
self.reference_draft(project, *address, facet, &intent.context.path)
|
||||
})
|
||||
.is_some_and(|draft| draft.reference_value(project, intent).is_ok());
|
||||
if !valid {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Event::Editor(RowEvent::ReferenceSet(_)) => return,
|
||||
_ => {}
|
||||
}
|
||||
if let Err(error) = self.frozen(project) {
|
||||
// Withdrawing a pending reset is authored intent only. Retain it while
|
||||
// keeping the original accepted base and disabling every stale plan.
|
||||
@@ -533,8 +464,7 @@ impl State {
|
||||
);
|
||||
let scoped_edit = matches!(
|
||||
event,
|
||||
RowEvent::ReferenceSet(_)
|
||||
| RowEvent::Input(_, _)
|
||||
RowEvent::Input(_, _)
|
||||
| RowEvent::Text(_, _)
|
||||
| RowEvent::Shape(_, _)
|
||||
| RowEvent::Variant(_, _)
|
||||
@@ -694,7 +624,7 @@ impl State {
|
||||
Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text},
|
||||
};
|
||||
let message = |event| Message::Workflow(crate::workflow::Event::semantic(event));
|
||||
let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event));
|
||||
let mut available: Vec<_> = self
|
||||
.base
|
||||
.traces
|
||||
@@ -817,19 +747,13 @@ impl State {
|
||||
if let Some(notice) = &self.notice {
|
||||
header = header.push(text(notice).size(11));
|
||||
}
|
||||
let view = self
|
||||
.active
|
||||
self.active
|
||||
.view(&self.base, header.into(), self.can_apply())
|
||||
.map(move |message| match message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event)) => {
|
||||
let event = match *event {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::semantic(event))
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
crate::forms::reference_chooser::trigger::modal(view)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,10 +522,10 @@ fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() {
|
||||
&mut app,
|
||||
RowEvent::Input(path(f.direction, "minimum"), "120".into()),
|
||||
);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
|
||||
)));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::OpenTrace,
|
||||
)));
|
||||
tests::decide(
|
||||
|
||||
@@ -166,14 +166,6 @@ impl ReviewRow {
|
||||
pub fn update(&mut self, project: &Project, event: RowEvent) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
// Browser messages belong to the app overlay and must never replan.
|
||||
RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_) => return,
|
||||
RowEvent::ReferenceSet(intent) => {
|
||||
let path = intent.context.path.clone();
|
||||
self.edit(project, &path, |draft| {
|
||||
draft.reference_set(project, &intent)
|
||||
})
|
||||
}
|
||||
RowEvent::OperationOmission(enabled) => {
|
||||
self.operation_omission =
|
||||
enabled.then(|| self.operation_omission.clone().unwrap_or_default());
|
||||
@@ -526,25 +518,6 @@ impl ReviewRow {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reference_draft(
|
||||
&self,
|
||||
project: &Project,
|
||||
facet: &FacetSelector,
|
||||
path: &str,
|
||||
) -> Option<&Draft> {
|
||||
if self.selected.as_ref() != Some(facet)
|
||||
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
|| matches!(path, "/id" | "/alias" | "/body" | "/layer")
|
||||
|| !self
|
||||
.candidate_roots(project, facet)?
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.candidate_draft(facet)
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
@@ -922,9 +895,7 @@ pub fn form_event(message: Message) -> Message {
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
RowEvent::ReferenceBrowse(path, query, page)
|
||||
}
|
||||
Message::FieldReferenceOpen(request) => RowEvent::ReferenceOpen(request),
|
||||
Message::FieldReferenceEvent(event) => RowEvent::ReferenceEvent(event),
|
||||
_ => RowEvent::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event)))
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{app::App, document::Document, workflow::Workflow};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn send(app: &mut App, event: RowEvent) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
state(app).address_event(event),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use iced::{
|
||||
mod reserved_artifact_tests;
|
||||
|
||||
fn event(event: RowEvent) -> Message {
|
||||
Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event)))
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
}
|
||||
fn action<'a>(
|
||||
label: impl Into<String>,
|
||||
@@ -867,13 +867,7 @@ impl ReviewRow {
|
||||
) {
|
||||
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
|
||||
}
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
||||
{
|
||||
crate::forms::reference_chooser::trigger::scope(detail, draft.reference_session())
|
||||
} else {
|
||||
detail.into()
|
||||
}
|
||||
detail.into()
|
||||
}
|
||||
|
||||
fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
|
||||
@@ -12,7 +12,6 @@ use iced::{
|
||||
},
|
||||
};
|
||||
use oa_core::*;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub fn label<'a>(s: impl Into<String>) -> widget::Text<'a> {
|
||||
text(s.into()).size(11).color(style::MUTED)
|
||||
@@ -25,12 +24,10 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
|
||||
}
|
||||
pub fn short(s: &str, limit: usize) -> String {
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if s.graphemes(true).count() > limit {
|
||||
if s.chars().count() > limit {
|
||||
format!(
|
||||
"{}…",
|
||||
s.graphemes(true)
|
||||
.take(limit.saturating_sub(1))
|
||||
.collect::<String>()
|
||||
s.chars().take(limit.saturating_sub(1)).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
s
|
||||
@@ -1293,57 +1290,3 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::short;
|
||||
|
||||
#[test]
|
||||
fn short_preserves_native_lifeline_clusters_at_cutoff() {
|
||||
let oa = "abcdefghijklmnope\u{0301}尾";
|
||||
assert_eq!(short(oa, 17), "abcdefghijklmnop…");
|
||||
assert_eq!(short(oa, 18), oa);
|
||||
assert_eq!(short(oa, 19), oa);
|
||||
|
||||
let sa = "abcdefghijklmnop👩\u{200d}🔬 tail";
|
||||
assert_eq!(short(sa, 17), "abcdefghijklmnop…");
|
||||
assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…");
|
||||
assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …");
|
||||
assert_eq!(short(sa, 22), sa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_never_splits_retained_extended_graphemes() {
|
||||
for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] {
|
||||
let value = format!("A{cluster}BC");
|
||||
assert_eq!(short(&value, 2), "A…");
|
||||
assert_eq!(short(&value, 3), format!("A{cluster}…"));
|
||||
assert_eq!(short(&value, 4), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_preserves_ascii_cjk_and_whitespace_policy() {
|
||||
assert_eq!(short("abcd", 3), "ab…");
|
||||
assert_eq!(short("abcd", 4), "abcd");
|
||||
assert_eq!(short("abcd", 5), "abcd");
|
||||
assert_eq!(short("甲乙丙丁", 3), "甲乙…");
|
||||
assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁");
|
||||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B");
|
||||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_preserves_empty_and_small_limit_policy() {
|
||||
for limit in [0, 1, 2] {
|
||||
assert_eq!(short("", limit), "");
|
||||
assert_eq!(short(" \t\r\n", limit), "");
|
||||
}
|
||||
assert_eq!(short("A", 0), "…");
|
||||
assert_eq!(short("A", 1), "A");
|
||||
assert_eq!(short("AB", 1), "…");
|
||||
assert_eq!(short("e\u{0301}", 0), "…");
|
||||
assert_eq!(short("e\u{0301}", 1), "e\u{0301}");
|
||||
assert_eq!(short("e\u{0301}B", 1), "…");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ mod transition_tests;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Semantic(Box<crate::semantic_review::Event>),
|
||||
Semantic(crate::semantic_review::Event),
|
||||
Port(crate::port_workflow::Event),
|
||||
ExchangeItem(Id, bool),
|
||||
Endpoint(bool, EndpointChoice),
|
||||
@@ -41,11 +41,6 @@ pub enum Event {
|
||||
Resolve(Id, TransitionField, FieldDecision),
|
||||
Preview,
|
||||
}
|
||||
impl Event {
|
||||
pub fn semantic(event: crate::semantic_review::Event) -> Self {
|
||||
Self::Semantic(Box::new(event))
|
||||
}
|
||||
}
|
||||
pub enum Workflow {
|
||||
Semantic(Box<crate::semantic_review::State>),
|
||||
Ports(Box<crate::port_workflow::State>),
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
# Integrated checkpoint — 2026-09-12
|
||||
# Integrated checkpoint — 2026-09-11
|
||||
|
||||
Codex-ytr83r, isolated branch `codex/canvas-union-ytr83r-20260911`. Current source `33e30c1` combines the a57 canvas, cb91525 grapheme and narrow saved-destination repair corrections. Its 530 frozen inputs match HEAD; independent repair review found no actionable finding. Windows desktop tests, strict Clippy, GUI build and bounded matching native/package acceptance passed. The [canvas-repair Windows prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.canvas-repair.20260912) is published; ZIP, checksum and validation attachments were downloaded and hash-verified. [Publication record](release-canvas-repair-publication-20260912.md) preserves the exact identities. This is not a full [user-goal](../PROJECT_GOAL.md) completion claim. Live [canvas integration](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2), [native/release acceptance](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5), [saved-destination repair](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6) and [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) retain ownership and plans. Earlier production `865483b` and its package remain preserved.
|
||||
Codex-ytr83r, branch `codex/release1-ytr83r`. The full [user goal](../PROJECT_GOAL.md) remains incomplete. Live integration/acceptance work is in [Gitea issue5](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5); the [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) and [process/scenario requirement](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2) link other work. This file scopes integrated code and evidence.
|
||||
|
||||
## Verified code and capabilities
|
||||
|
||||
Source `33e30c1` binds repair dirty state to the actual saved destination, preserving accepted-model history even when Apply is an identical-content no-op. It selectively incorporates peer `33c6d83` in three files. Windows focused repair **5**, full desktop **761**, strict desktop all-targets Clippy and GUI build passed; the five are included in 761. All **530 files / 9,727,025 bytes** remained unchanged between gates and matched HEAD after the build. The preserved GUI `50486253…` passed matching repair/history/recovery and Unicode selection/passive-save checks, packaged walkthrough/help smoke and normal exit0 with empty logs. All559 package files stayed unchanged; fresh extracted server25-request editing/persistence checks passed. [Current validation and artifact hashes](release-canvas-repair-validation-20260912.md) links the exact raw archives and independent reviews. [Repair union and limits](release-repair-union-ytr83r-20260912.md), [closed gates and exact inputs](experiments/repair-union-windows-33e30c1-20260912/README.md), [verbatim independent review](release-saved-destination-independent-review-20260912.md).
|
||||
|
||||
Source `cb91525` preserves extended grapheme clusters in the shared display helper. Its four focused tests, full desktop 756 and strict desktop Clippy passed; the four overlap the 756. Its GUI build was intentionally superseded by the repair union; matching combined33e native Unicode evidence is recorded above, without reattributing that result to cb91525. [Correction and limits](release-grapheme-shortening-ytr83r-20260911.md), [closed Windows evidence](experiments/grapheme-windows-cb91525-20260911/README.md).
|
||||
|
||||
The isolated `a57c334` union selectively combines peer `a31b40d` canvas/chooser/form prerequisites with B4 lifetime rendering and contextual source/child-work corrections. Windows lifecycle **27**, full desktop **752**, strict desktop Clippy and Studio build passed on 529 unchanged frozen inputs. GUI `69DBB90F…` passed bounded staged source/consumer creation, Cancel, Save, Undo/Redo and reopen checks: 13 oracles and 45 screenshots at the default wide window only. [Canvas checkpoint](release-canvas-union-ytr83r-20260911.md), [staged archive](experiments/staged-source-native-a57c334-20260911/README.md), [independent review](release-staged-native-a57-independent-review-20260911.md). A fresh a57 camera session subsequently passed bounded OA 100%/125%/Fit and minimum 1100×700 client Fit66 checks, OA/SA pan and moved Create/Delete hit selection, exact passive Save equality and normal exit 0. The 18 images support bounded clipping without toolbar bleed; labels are small at 66%. [Camera archive and limits](experiments/canvas-camera-a57c334-20260912/README.md), [verbatim independent review](release-camera-a57-independent-review-20260912.md). The [earlier picker failure](experiments/canvas-native-close-a57c334-20260911/README.md) remains historical evidence. These pre-grapheme a57 results do not establish current `33e30c1` native or complete usability acceptance.
|
||||
|
||||
Earlier published checkpoint `3ade397008cffe826d5f5a70e7c0233159e3bd42` contains production `29084050a7c020e0fb488be37c6e1ab235769cc0`; push and exact remote readback passed. It combines prior named behavior creation/history integration (`c81e285`, `e207b04`), corrected creation-oracle evidence (`b853a1e`), CLI-only integration (`023e3a8`) and B4 lifecycle rendering.
|
||||
|
||||
B4 renders local lifetimes, branch-local activation termination, neutral incomplete lanes, Create capsules and Delete markers with original occurrence identity. Real Windows OA/SA six-case matrices and separate name-only Save/Undo/Redo/reopen workflows passed complete independent Project, reachable-source and physical-file comparisons. These are bounded fixture checks, not complete product usability acceptance.
|
||||
@@ -21,7 +15,7 @@ B4 renders local lifetimes, branch-local activation termination, neutral incompl
|
||||
|
||||
Source `321897fd55af9e07afa2c6ebf1e50be49e86f7d9` integrates the published contextual source-creation/minimum-card corrections from b2c3e540 with B4 and the header clipping correction. Its own Windows desktop all-targets gate passed 704 tests. [Exact selective integration, independent union review and Windows results](release-contextual-integration-update-ytr83r-20260911.md).
|
||||
|
||||
Published production `865483b4c66361a01fe0d9e731859220bafb4136` adds the reviewed child-work correction: first refinement transfers existing Allocation and RoleAssignment identities, metadata and artifact references to the child; later siblings inherit only a unique performer. OA/SA regressions cover nested children, roles, folded grouping, unallocated/mixed controls and exact history. The old extracted Studio `BF9C2E61…` passed OA first-child, sibling and grandchild Save/Undo/Redo checks, then confirmed OA reopen/Save and the SA first/sibling/grandchild, Undo/Redo and reopen/Save continuation. Original identity carriers and complete source/physical snapshots matched. These are native results for **865483b/BF9**, not a57/69DB. [Original OA evidence](experiments/native-child-oa-865483b-20260911/README.md), [closed continuation](experiments/native-child-continuation-865483b-20260911/README.md), [independent continuation review](release-native-child-continuation-review-20260911.md), [fixture/oracle](fixtures/child-work-native/README.md) and [issue4](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4) preserve the exact scope.
|
||||
Published production `865483b4c66361a01fe0d9e731859220bafb4136` adds the reviewed child-work correction: first refinement transfers existing Allocation and RoleAssignment identities, metadata and artifact references to the child; later siblings inherit only a unique performer. OA/SA regressions cover nested children, roles, folded grouping, unallocated/mixed controls and exact history. The extracted Studio `BF9C2E61…` passed actual OA first-child, sibling and grandchild creation plus Save/Undo/Redo comparisons against original complete model/source checkpoints. Reopening and the SA native counterpart remain unverified. [Child-work fixture and oracle](fixtures/child-work-native/README.md); [native OA evidence](experiments/native-child-oa-865483b-20260911/README.md); [live acceptance boundary](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4).
|
||||
|
||||
## Verification scope
|
||||
|
||||
@@ -36,15 +30,13 @@ The closed 321897f Windows gate passed 704 desktop tests with zero failed/ignore
|
||||
|
||||
The later 865483b Windows desktop gate passed **707 tests**, including the three focused child-work regressions, with zero failed/ignored/filtered. All 261 recorded build inputs stayed unchanged. The matching debug GUI build and strict **desktop-only** all-targets Clippy passed; child-work and staged-source checker compilation/admission also passed. [Closed gates and exact inputs](experiments/windows-gates-865483b-20260911/README.md). These checks do not establish the remaining native rendering or packaged acceptance.
|
||||
|
||||
The extracted package's unchanged server `ABA87A5F…` passed the actual HTTP schema checks (204/19/9) and 25-request REST editing/persistence/restart probe with a restricted process PATH and unrelated working directory. Seven raw response pairs and exact source snapshots matched; all five test-owned server processes stopped. The first REST run's PowerShell-added PATH directory is preserved as historical evidence; the corrected restricted-PATH rerun is authoritative. [Packaged server evidence](experiments/packaged-server-865483b-20260911/README.md). This completed the earlier bounded server runtime acceptance. Fresh33e package/server/GUI results are linked above; the old receipts retain their own source attribution.
|
||||
The extracted package's unchanged server `ABA87A5F…` passed the actual HTTP schema checks (204/19/9) and 25-request REST editing/persistence/restart probe with a restricted process PATH and unrelated working directory. Seven raw response pairs and exact source snapshots matched; all five test-owned server processes stopped. The first REST run's PowerShell-added PATH directory is preserved as historical evidence; the corrected restricted-PATH rerun is authoritative. [Packaged server evidence](experiments/packaged-server-865483b-20260911/README.md). This completes bounded server runtime acceptance, while the new Windows preview remains unpublished pending the separate GUI checks.
|
||||
|
||||
## Material limitations
|
||||
|
||||
Earlier native wide Latin/CJK checks found neutral lifeline header text overflowing its card while Create labels stayed contained. The [header correction](lifecycle-header-overflow-correction-ytr83r-20260911.md) is included in 865483b and retained in a57. Actual corrected wide-W/CJK observations in BF9 show bounded headers and Create labels, including selected zoom, scroll and minimum-window cases. Both complete wide and cluster sources remained unchanged after passive Save. Cluster checks exposed a separate scalar-shortening defect: an accent or joined emoji can be split at the label cutoff. The later cb91525 correction and current33e accent/ZWJ selection/passive-save checks resolve the observed string-splitting case. Long wide labels can still be hard-clipped and arbitrary font/DPI presentation is unverified; see the historical [native typography record](experiments/lifecycle-header-correction-native-865483b-20260911/README.md).
|
||||
Native wide Latin/CJK checks found neutral lifeline header text overflowing its card; Create labels remained contained. The [header correction](lifecycle-header-overflow-correction-ytr83r-20260911.md) is included in the tested 865483b source, but matching corrected pixels and combining/emoji native appearance remain unverified. The old GUI's wide-name passive Save pass establishes identity/model/source/file equality, not corrected rendering. Complete combined native and packaged acceptance remain separate. During the OA reopen check, the Computer helper repeatedly failed with `window capture timed out: timed out waiting on channel`; fresh window selection and one clean helper restart did not recover capture. Native input stopped at the Open dialog with the accepted grandchild result already saved. This is a validation-tool failure, not evidence of a model failure or a successful reopen.
|
||||
|
||||
The earlier capture failures and pending Open dialog are historical: later confirmed OA and SA reopen/Save actions are preserved in the [continuation archive](experiments/native-child-continuation-865483b-20260911/README.md). Its first OA `reopened-1948` receipts remain data-only evidence. Bounded a57 staged and camera workflows above are complete; full focus/keyboard, every clipping edge/zoom and accessibility remain unverified. Bounded combined `33e30c1` native/package acceptance is now linked above. Small 66% labels are a recorded limitation. The BF9 Home page, scenario guide and bundled water-pressure example opened; one normal Alt+F4 then completed, with the process absent and empty stdout/stderr. The [old Home/Guide/close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves those old865/BF9 observations and package readback. No exit code was captured for that older BF9 process; the separate a57 exit 0 does not change its evidence.
|
||||
|
||||
The selected chooser/canvas union now has a57 source and Windows gate evidence; broader persistent graphical manipulation and separate text-navigation contributions retain their own integration/acceptance boundaries. Calculations, quantities/units, maintained libraries, practical large-model textual workflows, complete OA↔SA engineering parity, documentation and fresh cross-disciplinary review remain full-goal requirements. See the [analysis proposal](release-analysis-semantics-design-20260910.md), [library proposal](release-libraries-semantics-design-20260910.md), [text-authoring design](release-textual-authoring-design-20260911.md) and [decisions](DECISIONS.md) for attributed design evidence, not completed implementation claims.
|
||||
Other chooser/canvas and text-navigation contributions still require their own integration and verification. Calculations, quantities/units, maintained libraries, practical large-model textual workflows, complete OA↔SA engineering parity, documentation and fresh cross-disciplinary review remain full-goal requirements. See the [analysis proposal](release-analysis-semantics-design-20260910.md), [library proposal](release-libraries-semantics-design-20260910.md), [text-authoring design](release-textual-authoring-design-20260911.md) and [decisions](DECISIONS.md) for attributed design evidence, not completed implementation claims.
|
||||
|
||||
## Platform and published deliverables
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Bounded a57 camera and minimum-window native checks
|
||||
|
||||
Root exercised source `a57c334b302eb13352b4bb65df1c75d94d002348`, unoptimized GUI SHA256 `69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40`, in a fresh isolated session on 2026-09-12. Launch and close bind PID 5004 and unchanged GUI/runtime hashes. This is the pre-grapheme a57 executable: no clusters, native combined-source, all-zoom, package or full-product acceptance follows.
|
||||
|
||||
The archiver independently viewed all 18 original JPEGs and checked the closed receipts; no new UI, oracle, compiler or application action was performed. Image 01 shows the wide source project opened. Images 02–06 show OA wide-W lifecycle at 100%, Create selected as `life_oa_110`, zoom 125%, Delete selected as `life_oa_112`, and Fit restoring 100%. Header text and Create text remain within the visible card/capsule or viewport; at 125% the headers are partly above the viewport, with no visible spill into the toolbar.
|
||||
|
||||
Images 07–08 document native resizing from 1442×932 to 1102×932 and then 1102×732 captured frame; root reports the final client as 1100×700. At 100% the narrow viewport cuts the right side of the receiver card/capsule at its boundary. Images 09–12 show Fit 66%, OA Create selection, content shifted 60 pixels left after root's Right pan, and Delete selected at its new position with alias `life_oa_112`. The left header is then partly outside the viewport and clipped there. Images 13–17 show the SA CJK receiver: switching layer restores 100%; Fit gives 66%; Create selects `life_sa_110`; content moves 60 pixels up after root's Down pan, leaving headers above the viewport; selecting the moved Delete shows `life_sa_112`. No visible header bleed into the toolbar is present. Exact keyboard inputs and camera values (-60,0)/(0,-60) are root's action account, while the images independently support the resulting displacement, zoom labels and selection identities.
|
||||
|
||||
At 66% labels are visibly small. These screenshots do not establish general readability, accessibility, focus/keyboard completeness, every clipping edge or every zoom. The visible 19 model checks are not zero diagnostics; the fixture intentionally contains sparse lifecycle cases. No model editing is inferred from selection or camera navigation.
|
||||
|
||||
Image 18 records **All source files saved**. The closed lifecycle `same` invocation compares the original `baseline-wide/project.arcadia` with `checkpoints/wide-passive/project.arcadia`, exiting 0 at 2026-09-12T11:02:28.993557Z with `PASS same: all identities, fields, reachable source and physical bytes`. A fresh archival all-file comparison also confirms both directories contain exactly the same two files, 20,214 bytes total. The checker source, historical compilation/self-check/admission and binary/input provenance are included; no fresh checker execution or compilation is claimed by this archive.
|
||||
|
||||
Root reports two temporary 1×16 captures during Windows sizing, recovered by fresh window selection and full capture before coordinate input. Those tiny captures are not among the 18 saved JPEGs and are not independently reconstructed here. Root then reports one Alt+F4 and fresh inventory without Studio. The waiting logger independently records exit 0 at 2026-09-12T11:02:56.0410831Z; closed stdout/stderr are empty. Launch's pending acceptance text is historical. This fresh successful bounded run is separate from the [earlier camera picker failure and close](../canvas-native-close-a57c334-20260911/README.md).
|
||||
|
||||
`manifest.json` binds exact original/copy bytes. `readback.json` independently rereads original payloads, archived payloads and final metadata inventory. Preparation and provenance may mention clusters, other oracles, ZIPs, working files or prior gates; these are historical references, not acceptance of those cases. Executables, DLLs, ZIPs, profiles and live working directories are excluded. Source-only archival/preparation/launch scripts are records, not instructions to rerun. Root owns integration and publication.
|
||||
@@ -1,29 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$originRoot = 'C:\Users\User\Documents\Git\ARCADIA-Studio\.local\native-a57c334-camera'
|
||||
$destinationRoot = $PSScriptRoot
|
||||
$paths = @('launch-script-syntax.json','launch.json','normal-close.json','preparation-manifest.json','prepare-inputs.py','second-readback.json','wide-passive-status.json')
|
||||
foreach ($directory in @('baseline-wide','checkpoints/wide-passive','logs','provenance','screenshots')) {
|
||||
$paths += Get-ChildItem -LiteralPath (Join-Path $originRoot $directory) -Recurse -File | ForEach-Object { $_.FullName.Substring($originRoot.Length + 1).Replace('\','/') }
|
||||
}
|
||||
$entries = @()
|
||||
foreach ($relative in ($paths | Sort-Object)) {
|
||||
$original = Join-Path $originRoot $relative
|
||||
$copy = Join-Path $destinationRoot $relative
|
||||
if (Test-Path -LiteralPath $copy) { throw "Refusing to overwrite $copy" }
|
||||
$before = (Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash
|
||||
New-Item -ItemType Directory -Path (Split-Path $copy) -Force | Out-Null
|
||||
Copy-Item -LiteralPath $original -Destination $copy
|
||||
$after = (Get-FileHash -LiteralPath $copy -Algorithm SHA256).Hash
|
||||
if ($before -ne $after) { throw "Copy mismatch $relative" }
|
||||
$entries += [ordered]@{path=$relative;original=$original;bytes=(Get-Item -LiteralPath $copy).Length;original_sha256=$before;copy_sha256=$after}
|
||||
}
|
||||
$original = 'C:\Users\User\Documents\Git\ARCADIA-Studio\.local\launch-native-a57c334-camera.ps1'
|
||||
$copy = Join-Path $destinationRoot 'launch-native-a57c334-camera.ps1'
|
||||
if (Test-Path -LiteralPath $copy) { throw "Refusing to overwrite $copy" }
|
||||
$before = (Get-FileHash -LiteralPath $original).Hash
|
||||
Copy-Item -LiteralPath $original -Destination $copy
|
||||
$after = (Get-FileHash -LiteralPath $copy).Hash
|
||||
if ($before -ne $after) { throw 'Launch script mismatch' }
|
||||
$entries += [ordered]@{path='launch-native-a57c334-camera.ps1';original=$original;bytes=(Get-Item -LiteralPath $copy).Length;original_sha256=$before;copy_sha256=$after}
|
||||
$manifest = [ordered]@{created_utc=[DateTime]::UtcNow.ToString('o');scope='Closed a57 camera evidence only; copying does not execute gates or UI';payload_count=$entries.Count;payload_bytes=($entries|ForEach-Object { $_.bytes }|Measure-Object -Sum).Sum;files=$entries;excluded=@('bin','oracles executables/DLL','baseline-clusters','working-wide','working-clusters','profile','unrelated-cwd','ZIP archives')}
|
||||
[IO.File]::WriteAllText((Join-Path $destinationRoot 'manifest.json'),($manifest|ConvertTo-Json -Depth 6)+"`n",[Text.UTF8Encoding]::new($false))
|
||||
@@ -1,692 +0,0 @@
|
||||
arcadia 7;
|
||||
project lifecycle_native "Lifecycle native contract" id "5ceab400-0000-4000-8000-000000000001" {
|
||||
include "rationale.arcadia";
|
||||
description "Fixed semantic inputs; lifecycle geometry is derived and never authored.";
|
||||
|
||||
oa_activity life_oa_10 "OA Controller work" id "5ceab400-0000-4000-8000-0000000003f2" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_activity life_oa_11 "OA Receiver work" id "5ceab400-0000-4000-8000-0000000003f3" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_scenario life_oa_100 "OA 01 Root create action delete" id "5ceab400-0000-4000-8000-00000000044c" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_110, @life_oa_111, @life_oa_112];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_101 "OA Controller lane" id "5ceab400-0000-4000-8000-00000000044d" {
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_102 "WWWWWWWWWWWWWWWWWW" id "5ceab400-0000-4000-8000-00000000044e" {
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_110 "OA Create receiver" id "5ceab400-0000-4000-8000-000000000456" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Create;
|
||||
receiver @life_oa_102;
|
||||
reply_to none;
|
||||
sender @life_oa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_111 "OA Receiver action" id "5ceab400-0000-4000-8000-000000000457" {
|
||||
body {
|
||||
Action {
|
||||
action @life_oa_11;
|
||||
lifeline @life_oa_102;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_112 "OA Delete receiver" id "5ceab400-0000-4000-8000-000000000458" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_102;
|
||||
reply_to none;
|
||||
sender @life_oa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_200 "OA 02 Preexisting empty" id "5ceab400-0000-4000-8000-0000000004b0" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_201 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000004b1" {
|
||||
description "";
|
||||
parent @life_oa_200;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_202 "OA Receiver lane" id "5ceab400-0000-4000-8000-0000000004b2" {
|
||||
description "";
|
||||
parent @life_oa_200;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_scenario life_oa_300 "OA 03 Create only" id "5ceab400-0000-4000-8000-000000000514" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_310];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_301 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000515" {
|
||||
description "";
|
||||
parent @life_oa_300;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_302 "OA Receiver lane" id "5ceab400-0000-4000-8000-000000000516" {
|
||||
description "";
|
||||
parent @life_oa_300;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_310 "OA Create only receiver" id "5ceab400-0000-4000-8000-00000000051e" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Create;
|
||||
receiver @life_oa_302;
|
||||
reply_to none;
|
||||
sender @life_oa_301;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_300;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_400 "OA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000578" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_410, @life_oa_411];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_401 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000579" {
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_402 "OA Receiver lane" id "5ceab400-0000-4000-8000-00000000057a" {
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_410 "OA Open call" id "5ceab400-0000-4000-8000-000000000582" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Call;
|
||||
receiver @life_oa_402;
|
||||
reply_to none;
|
||||
sender @life_oa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_411 "OA ALT delete or keep" id "5ceab400-0000-4000-8000-000000000583" {
|
||||
body {
|
||||
Fragment {
|
||||
branches [@life_oa_412, @life_oa_413];
|
||||
operator Alternative;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_412 "OA Left delete operand" id "5ceab400-0000-4000-8000-000000000584" {
|
||||
body {
|
||||
Branch {
|
||||
guard "stop_requested";
|
||||
occurrences [@life_oa_414];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_413 "OA Right empty operand" id "5ceab400-0000-4000-8000-000000000585" {
|
||||
body {
|
||||
Branch {
|
||||
guard "else";
|
||||
occurrences [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_414 "OA Conditional delete receiver" id "5ceab400-0000-4000-8000-000000000586" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_402;
|
||||
reply_to none;
|
||||
sender @life_oa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_412;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_500 "OA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000005dc" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_510, @life_oa_511];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_501 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000005dd" {
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_502 "OA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000005de" {
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
represented none;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_510 "OA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e6" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Create;
|
||||
receiver @life_oa_502;
|
||||
reply_to none;
|
||||
sender @life_oa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_511 "OA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e7" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_502;
|
||||
reply_to none;
|
||||
sender @life_oa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_600 "OA 06 Self delete" id "5ceab400-0000-4000-8000-000000000640" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_610];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_601 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000641" {
|
||||
description "";
|
||||
parent @life_oa_600;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_610 "OA Self delete controller" id "5ceab400-0000-4000-8000-00000000064a" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_601;
|
||||
reply_to none;
|
||||
sender @life_oa_601;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_600;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_function life_sa_10 "SA Controller work" id "5ceab400-0000-4000-8000-0000000007da" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_function life_sa_11 "SA Receiver work" id "5ceab400-0000-4000-8000-0000000007db" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_scenario life_sa_100 "SA 01 Root create action delete" id "5ceab400-0000-4000-8000-000000000834" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_110, @life_sa_111, @life_sa_112];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_101 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000835" {
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_102 "界界界界界界界界界界界界界界界界界界" id "5ceab400-0000-4000-8000-000000000836" {
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_110 "SA Create receiver" id "5ceab400-0000-4000-8000-00000000083e" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Create;
|
||||
receiver @life_sa_102;
|
||||
reply_to none;
|
||||
sender @life_sa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_111 "SA Receiver action" id "5ceab400-0000-4000-8000-00000000083f" {
|
||||
body {
|
||||
Action {
|
||||
action @life_sa_11;
|
||||
lifeline @life_sa_102;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_112 "SA Delete receiver" id "5ceab400-0000-4000-8000-000000000840" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_102;
|
||||
reply_to none;
|
||||
sender @life_sa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_200 "SA 02 Preexisting empty" id "5ceab400-0000-4000-8000-000000000898" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_201 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000899" {
|
||||
description "";
|
||||
parent @life_sa_200;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_202 "SA Receiver lane" id "5ceab400-0000-4000-8000-00000000089a" {
|
||||
description "";
|
||||
parent @life_sa_200;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_scenario life_sa_300 "SA 03 Create only" id "5ceab400-0000-4000-8000-0000000008fc" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_310];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_301 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000008fd" {
|
||||
description "";
|
||||
parent @life_sa_300;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_302 "SA Receiver lane" id "5ceab400-0000-4000-8000-0000000008fe" {
|
||||
description "";
|
||||
parent @life_sa_300;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_310 "SA Create only receiver" id "5ceab400-0000-4000-8000-000000000906" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Create;
|
||||
receiver @life_sa_302;
|
||||
reply_to none;
|
||||
sender @life_sa_301;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_300;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_400 "SA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000960" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_410, @life_sa_411];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_401 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000961" {
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_402 "SA Receiver lane" id "5ceab400-0000-4000-8000-000000000962" {
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_410 "SA Open call" id "5ceab400-0000-4000-8000-00000000096a" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Call;
|
||||
receiver @life_sa_402;
|
||||
reply_to none;
|
||||
sender @life_sa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_411 "SA ALT delete or keep" id "5ceab400-0000-4000-8000-00000000096b" {
|
||||
body {
|
||||
Fragment {
|
||||
branches [@life_sa_412, @life_sa_413];
|
||||
operator Alternative;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_412 "SA Left delete operand" id "5ceab400-0000-4000-8000-00000000096c" {
|
||||
body {
|
||||
Branch {
|
||||
guard "stop_requested";
|
||||
occurrences [@life_sa_414];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_413 "SA Right empty operand" id "5ceab400-0000-4000-8000-00000000096d" {
|
||||
body {
|
||||
Branch {
|
||||
guard "else";
|
||||
occurrences [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_414 "SA Conditional delete receiver" id "5ceab400-0000-4000-8000-00000000096e" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_402;
|
||||
reply_to none;
|
||||
sender @life_sa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_412;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_500 "SA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000009c4" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_510, @life_sa_511];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_501 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000009c5" {
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_502 "SA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000009c6" {
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
represented none;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_510 "SA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000009ce" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Create;
|
||||
receiver @life_sa_502;
|
||||
reply_to none;
|
||||
sender @life_sa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_511 "SA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000009cf" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_502;
|
||||
reply_to none;
|
||||
sender @life_sa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_600 "SA 06 Self delete" id "5ceab400-0000-4000-8000-000000000a28" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_610];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_601 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000a29" {
|
||||
description "";
|
||||
parent @life_sa_600;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_610 "SA Self delete controller" id "5ceab400-0000-4000-8000-000000000a32" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_601;
|
||||
reply_to none;
|
||||
sender @life_sa_601;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_600;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_port sa_lifecycle_service_input "SA Lifecycle service input" id "a4a6ecc0-3e12-51a5-b08b-199ad743525c" {
|
||||
contract {
|
||||
FunctionInput {
|
||||
incoming_items [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
legacy none;
|
||||
parent @life_sa_11;
|
||||
}
|
||||
|
||||
sa_port sa_lifecycle_service_output "SA Lifecycle service output" id "b1aa66c4-0e8e-5091-beda-2b8d2005b688" {
|
||||
contract {
|
||||
FunctionOutput {
|
||||
outgoing_items [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
legacy none;
|
||||
parent @life_sa_10;
|
||||
}
|
||||
|
||||
interaction life_oa_service "OA Lifecycle service" id "5ceab400-0000-4000-8000-0000000003f4" {
|
||||
description "";
|
||||
items [];
|
||||
source @life_oa_10;
|
||||
target @life_oa_11;
|
||||
}
|
||||
|
||||
functional_exchange life_sa_service "SA Lifecycle service" id "5ceab400-0000-4000-8000-0000000007dc" {
|
||||
description "";
|
||||
items [];
|
||||
source @life_sa_10;
|
||||
source_port @sa_lifecycle_service_output;
|
||||
target @life_sa_11;
|
||||
target_port @sa_lifecycle_service_input;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Preserve lifecycle note: 雨 · receiver.
|
||||
// Geometry never changes these bytes.
|
||||
@@ -1,692 +0,0 @@
|
||||
arcadia 7;
|
||||
project lifecycle_native "Lifecycle native contract" id "5ceab400-0000-4000-8000-000000000001" {
|
||||
include "rationale.arcadia";
|
||||
description "Fixed semantic inputs; lifecycle geometry is derived and never authored.";
|
||||
|
||||
oa_activity life_oa_10 "OA Controller work" id "5ceab400-0000-4000-8000-0000000003f2" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_activity life_oa_11 "OA Receiver work" id "5ceab400-0000-4000-8000-0000000003f3" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
oa_scenario life_oa_100 "OA 01 Root create action delete" id "5ceab400-0000-4000-8000-00000000044c" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_110, @life_oa_111, @life_oa_112];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_101 "OA Controller lane" id "5ceab400-0000-4000-8000-00000000044d" {
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_102 "WWWWWWWWWWWWWWWWWW" id "5ceab400-0000-4000-8000-00000000044e" {
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_110 "OA Create receiver" id "5ceab400-0000-4000-8000-000000000456" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Create;
|
||||
receiver @life_oa_102;
|
||||
reply_to none;
|
||||
sender @life_oa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_111 "OA Receiver action" id "5ceab400-0000-4000-8000-000000000457" {
|
||||
body {
|
||||
Action {
|
||||
action @life_oa_11;
|
||||
lifeline @life_oa_102;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_112 "OA Delete receiver" id "5ceab400-0000-4000-8000-000000000458" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_102;
|
||||
reply_to none;
|
||||
sender @life_oa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_200 "OA 02 Preexisting empty" id "5ceab400-0000-4000-8000-0000000004b0" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_201 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000004b1" {
|
||||
description "";
|
||||
parent @life_oa_200;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_202 "OA Receiver lane" id "5ceab400-0000-4000-8000-0000000004b2" {
|
||||
description "";
|
||||
parent @life_oa_200;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_scenario life_oa_300 "OA 03 Create only" id "5ceab400-0000-4000-8000-000000000514" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_310];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_301 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000515" {
|
||||
description "";
|
||||
parent @life_oa_300;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_302 "OA Receiver lane" id "5ceab400-0000-4000-8000-000000000516" {
|
||||
description "";
|
||||
parent @life_oa_300;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_310 "OA Create only receiver" id "5ceab400-0000-4000-8000-00000000051e" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Create;
|
||||
receiver @life_oa_302;
|
||||
reply_to none;
|
||||
sender @life_oa_301;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_300;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_400 "OA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000578" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_410, @life_oa_411];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_401 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000579" {
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_402 "OA Receiver lane" id "5ceab400-0000-4000-8000-00000000057a" {
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
represented @life_oa_11;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_410 "OA Open call" id "5ceab400-0000-4000-8000-000000000582" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Call;
|
||||
receiver @life_oa_402;
|
||||
reply_to none;
|
||||
sender @life_oa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_411 "OA ALT delete or keep" id "5ceab400-0000-4000-8000-000000000583" {
|
||||
body {
|
||||
Fragment {
|
||||
branches [@life_oa_412, @life_oa_413];
|
||||
operator Alternative;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_412 "OA Left delete operand" id "5ceab400-0000-4000-8000-000000000584" {
|
||||
body {
|
||||
Branch {
|
||||
guard "stop_requested";
|
||||
occurrences [@life_oa_414];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_413 "OA Right empty operand" id "5ceab400-0000-4000-8000-000000000585" {
|
||||
body {
|
||||
Branch {
|
||||
guard "else";
|
||||
occurrences [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_414 "OA Conditional delete receiver" id "5ceab400-0000-4000-8000-000000000586" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_oa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_402;
|
||||
reply_to none;
|
||||
sender @life_oa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_412;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_500 "OA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000005dc" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_510, @life_oa_511];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_501 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000005dd" {
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_502 "OA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000005de" {
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
represented none;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_510 "OA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e6" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Create;
|
||||
receiver @life_oa_502;
|
||||
reply_to none;
|
||||
sender @life_oa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_511 "OA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e7" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_502;
|
||||
reply_to none;
|
||||
sender @life_oa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_scenario life_oa_600 "OA 06 Self delete" id "5ceab400-0000-4000-8000-000000000640" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_oa_610];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_lifeline life_oa_601 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000641" {
|
||||
description "";
|
||||
parent @life_oa_600;
|
||||
represented @life_oa_10;
|
||||
}
|
||||
|
||||
oa_occurrence life_oa_610 "OA Self delete controller" id "5ceab400-0000-4000-8000-00000000064a" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_oa_601;
|
||||
reply_to none;
|
||||
sender @life_oa_601;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_oa_600;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_function life_sa_10 "SA Controller work" id "5ceab400-0000-4000-8000-0000000007da" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_function life_sa_11 "SA Receiver work" id "5ceab400-0000-4000-8000-0000000007db" {
|
||||
description "";
|
||||
parent none;
|
||||
}
|
||||
|
||||
sa_scenario life_sa_100 "SA 01 Root create action delete" id "5ceab400-0000-4000-8000-000000000834" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_110, @life_sa_111, @life_sa_112];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_101 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000835" {
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_102 "界界界界界界界界界界界界界界界界界界" id "5ceab400-0000-4000-8000-000000000836" {
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_110 "SA Create receiver" id "5ceab400-0000-4000-8000-00000000083e" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Create;
|
||||
receiver @life_sa_102;
|
||||
reply_to none;
|
||||
sender @life_sa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_111 "SA Receiver action" id "5ceab400-0000-4000-8000-00000000083f" {
|
||||
body {
|
||||
Action {
|
||||
action @life_sa_11;
|
||||
lifeline @life_sa_102;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_112 "SA Delete receiver" id "5ceab400-0000-4000-8000-000000000840" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_102;
|
||||
reply_to none;
|
||||
sender @life_sa_101;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_100;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_200 "SA 02 Preexisting empty" id "5ceab400-0000-4000-8000-000000000898" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_201 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000899" {
|
||||
description "";
|
||||
parent @life_sa_200;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_202 "SA Receiver lane" id "5ceab400-0000-4000-8000-00000000089a" {
|
||||
description "";
|
||||
parent @life_sa_200;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_scenario life_sa_300 "SA 03 Create only" id "5ceab400-0000-4000-8000-0000000008fc" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_310];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_301 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000008fd" {
|
||||
description "";
|
||||
parent @life_sa_300;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_302 "SA Receiver lane" id "5ceab400-0000-4000-8000-0000000008fe" {
|
||||
description "";
|
||||
parent @life_sa_300;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_310 "SA Create only receiver" id "5ceab400-0000-4000-8000-000000000906" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Create;
|
||||
receiver @life_sa_302;
|
||||
reply_to none;
|
||||
sender @life_sa_301;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_300;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_400 "SA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000960" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_410, @life_sa_411];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_401 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000961" {
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_402 "SA Receiver lane" id "5ceab400-0000-4000-8000-000000000962" {
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
represented @life_sa_11;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_410 "SA Open call" id "5ceab400-0000-4000-8000-00000000096a" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Call;
|
||||
receiver @life_sa_402;
|
||||
reply_to none;
|
||||
sender @life_sa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_411 "SA ALT delete or keep" id "5ceab400-0000-4000-8000-00000000096b" {
|
||||
body {
|
||||
Fragment {
|
||||
branches [@life_sa_412, @life_sa_413];
|
||||
operator Alternative;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_400;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_412 "SA Left delete operand" id "5ceab400-0000-4000-8000-00000000096c" {
|
||||
body {
|
||||
Branch {
|
||||
guard "stop_requested";
|
||||
occurrences [@life_sa_414];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_413 "SA Right empty operand" id "5ceab400-0000-4000-8000-00000000096d" {
|
||||
body {
|
||||
Branch {
|
||||
guard "else";
|
||||
occurrences [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_411;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_414 "SA Conditional delete receiver" id "5ceab400-0000-4000-8000-00000000096e" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @life_sa_service;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_402;
|
||||
reply_to none;
|
||||
sender @life_sa_401;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_412;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_500 "SA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000009c4" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_510, @life_sa_511];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_501 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000009c5" {
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_502 "SA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000009c6" {
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
represented none;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_510 "SA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000009ce" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Create;
|
||||
receiver @life_sa_502;
|
||||
reply_to none;
|
||||
sender @life_sa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_511 "SA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000009cf" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_502;
|
||||
reply_to none;
|
||||
sender @life_sa_501;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_500;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_scenario life_sa_600 "SA 06 Self delete" id "5ceab400-0000-4000-8000-000000000a28" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@life_sa_610];
|
||||
viewpoint Functional;
|
||||
}
|
||||
|
||||
sa_lifeline life_sa_601 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000a29" {
|
||||
description "";
|
||||
parent @life_sa_600;
|
||||
represented @life_sa_10;
|
||||
}
|
||||
|
||||
sa_occurrence life_sa_610 "SA Self delete controller" id "5ceab400-0000-4000-8000-000000000a32" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange none;
|
||||
message_kind Delete;
|
||||
receiver @life_sa_601;
|
||||
reply_to none;
|
||||
sender @life_sa_601;
|
||||
values [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @life_sa_600;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
sa_port sa_lifecycle_service_input "SA Lifecycle service input" id "a4a6ecc0-3e12-51a5-b08b-199ad743525c" {
|
||||
contract {
|
||||
FunctionInput {
|
||||
incoming_items [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
legacy none;
|
||||
parent @life_sa_11;
|
||||
}
|
||||
|
||||
sa_port sa_lifecycle_service_output "SA Lifecycle service output" id "b1aa66c4-0e8e-5091-beda-2b8d2005b688" {
|
||||
contract {
|
||||
FunctionOutput {
|
||||
outgoing_items [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
legacy none;
|
||||
parent @life_sa_10;
|
||||
}
|
||||
|
||||
interaction life_oa_service "OA Lifecycle service" id "5ceab400-0000-4000-8000-0000000003f4" {
|
||||
description "";
|
||||
items [];
|
||||
source @life_oa_10;
|
||||
target @life_oa_11;
|
||||
}
|
||||
|
||||
functional_exchange life_sa_service "SA Lifecycle service" id "5ceab400-0000-4000-8000-0000000007dc" {
|
||||
description "";
|
||||
items [];
|
||||
source @life_sa_10;
|
||||
source_port @sa_lifecycle_service_output;
|
||||
target @life_sa_11;
|
||||
target_port @sa_lifecycle_service_input;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Preserve lifecycle note: 雨 · receiver.
|
||||
// Geometry never changes these bytes.
|
||||
@@ -1,183 +0,0 @@
|
||||
#Requires -Version 7.0
|
||||
# PREPARED ONLY. Root decides when to invoke. Keep this logger alive until normal GUI close.
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$nativeRoot = Join-Path $PSScriptRoot 'native-a57c334-camera'
|
||||
$nativeFreeze = 'a57c334b302eb13352b4bb65df1c75d94d002348'
|
||||
$nativeManifestHash = '293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB'
|
||||
$nativeInputHash = '47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3'
|
||||
$nativeGuiHash = '69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40'
|
||||
$nativeDllHash = 'AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A'
|
||||
$nativeBin = Join-Path $nativeRoot 'bin'
|
||||
$nativeProfile = Join-Path $nativeRoot 'profile'
|
||||
$nativeCwd = Join-Path $nativeRoot 'unrelated-cwd'
|
||||
$nativePath = 'C:\Windows\System32;C:\Windows;' + $nativeBin
|
||||
$nativeReceipt = Join-Path $nativeRoot 'launch.json'
|
||||
$nativeTerminal = Join-Path $nativeRoot 'normal-close.json'
|
||||
$nativeStdout = Join-Path $nativeRoot 'logs/studio.stdout.log'
|
||||
$nativeStderr = Join-Path $nativeRoot 'logs/studio.stderr.log'
|
||||
$nativeUtf8 = [Text.UTF8Encoding]::new($false)
|
||||
|
||||
function Read-NativeItem([string]$Path) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
$ancestor = $item
|
||||
while ($null -ne $ancestor) {
|
||||
if (($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Reparse path requires separate review: $($ancestor.FullName)"
|
||||
}
|
||||
$ancestor = if ($ancestor -is [IO.FileInfo]) { $ancestor.Directory } else { $ancestor.Parent }
|
||||
}
|
||||
return $item
|
||||
}
|
||||
function Read-NativeFile([string]$Path) {
|
||||
$item = Read-NativeItem $Path
|
||||
if ($item.PSIsContainer) { throw "Expected file: $Path" }
|
||||
return [ordered]@{ path = $item.FullName; bytes = $item.Length; sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash }
|
||||
}
|
||||
function Write-NativeRecord($Record, [IO.FileStream]$Stream) {
|
||||
$bytes = $nativeUtf8.GetBytes(($Record | ConvertTo-Json -Depth 12) + "`n")
|
||||
$Stream.Position = 0
|
||||
$Stream.SetLength(0)
|
||||
$Stream.Write($bytes, 0, $bytes.Length)
|
||||
$Stream.Flush($true)
|
||||
}
|
||||
function Open-NativeOutput([string]$Path) {
|
||||
return [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read)
|
||||
}
|
||||
|
||||
# Source provenance is the closed a57 gate, never current dirty sources or target/debug.
|
||||
$nativeManifestFile = Read-NativeFile (Join-Path $nativeRoot 'preparation-manifest.json')
|
||||
if ($nativeManifestFile.sha256 -ne $nativeManifestHash) { throw 'Preparation manifest changed.' }
|
||||
$nativeManifest = Get-Content -LiteralPath $nativeManifestFile.path -Raw | ConvertFrom-Json
|
||||
if ($nativeManifest.source_commit -ne $nativeFreeze -or $nativeManifest.input_manifest_sha256 -ne $nativeInputHash -or $nativeManifest.frozen_input_count -ne 529) { throw 'Unexpected frozen input identity.' }
|
||||
foreach ($file in $nativeManifest.files) {
|
||||
$path = [IO.Path]::GetFullPath((Join-Path $nativeRoot $file.target))
|
||||
if (-not $path.StartsWith($nativeRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'Manifest path escapes private root.' }
|
||||
$actual = Read-NativeFile $path
|
||||
if ($actual.sha256 -ne $file.sha256 -or $actual.bytes -ne $file.bytes) { throw "Prepared input changed: $($file.target)" }
|
||||
}
|
||||
$nativeInputs = Read-NativeFile (Join-Path $nativeRoot 'provenance/inputs.json')
|
||||
if ($nativeInputs.sha256 -ne $nativeInputHash) { throw 'Frozen input manifest changed.' }
|
||||
$nativeBuildFile = Read-NativeFile (Join-Path $nativeRoot 'provenance/studio-build-status.json')
|
||||
$nativeBuild = Get-Content -LiteralPath $nativeBuildFile.path -Raw | ConvertFrom-Json
|
||||
if ($nativeBuild.exit -ne 0 -or $nativeBuild.name -ne 'canvas-union-windows-20260911/studio-build' -or [string]::IsNullOrWhiteSpace($nativeBuild.completed_utc)) { throw 'Matching completed build required.' }
|
||||
$nativeGui = Read-NativeFile (Join-Path $nativeBin 'arcadia-studio.exe')
|
||||
$nativeDll = Read-NativeFile (Join-Path $nativeBin 'libunwind.dll')
|
||||
if ($nativeGui.sha256 -ne $nativeGuiHash -or $nativeGui.bytes -ne 113028608 -or $nativeDll.sha256 -ne $nativeDllHash -or $nativeDll.bytes -ne 190464) { throw 'Immutable GUI/runtime mismatch.' }
|
||||
if (-not [string]::Equals($env:SystemRoot, 'C:\Windows', [StringComparison]::OrdinalIgnoreCase)) { throw 'This recorded launch requires SystemRoot C:\Windows.' }
|
||||
foreach ($directory in @($nativeProfile, $nativeCwd, (Join-Path $nativeRoot 'logs'), 'C:\Windows\System32')) {
|
||||
if (-not (Read-NativeItem $directory).PSIsContainer) { throw "Required directory missing: $directory" }
|
||||
}
|
||||
foreach ($empty in @($nativeProfile, $nativeCwd)) {
|
||||
if (@(Get-ChildItem -LiteralPath $empty -Force).Count -ne 0) { throw "Fresh empty directory required: $empty" }
|
||||
}
|
||||
foreach ($output in @($nativeReceipt, $nativeTerminal, $nativeStdout, $nativeStderr)) {
|
||||
if (Test-Path -LiteralPath $output) { throw "Never overwrite prior launch evidence: $output" }
|
||||
}
|
||||
|
||||
$nativeState = [ordered]@{
|
||||
source_commit = $nativeFreeze
|
||||
scope = 'Independent pre-grapheme a57 camera/clipping/zoom/hit session; no package or new combined Unicode acceptance implied.'
|
||||
preparation_manifest = $nativeManifestFile
|
||||
build_inputs_manifest = $nativeInputs
|
||||
build_status_file = $nativeBuildFile
|
||||
build_status = $nativeBuild
|
||||
studio = $nativeGui
|
||||
runtime = $nativeDll
|
||||
launch_script = (Read-NativeFile $PSCommandPath)
|
||||
process_path = $nativePath
|
||||
isolated_localappdata = $nativeProfile
|
||||
working_directory = $nativeCwd
|
||||
arguments = @()
|
||||
environment_scope = 'Child ProcessStartInfo only; other normal inherited environment retained.'
|
||||
window_style = 'Hidden'
|
||||
use_shell_execute = $false
|
||||
create_no_window = $true
|
||||
stdout = $nativeStdout
|
||||
stderr = $nativeStderr
|
||||
pid = $null
|
||||
launch_requested_utc = $null
|
||||
process_started_utc = $null
|
||||
state = 'preflight_passed'
|
||||
acceptance = 'Pending root observation and independent source/model checks.'
|
||||
}
|
||||
$nativeReceiptStream = $null
|
||||
$nativeOutStream = $null
|
||||
$nativeErrStream = $null
|
||||
$nativeProcess = $null
|
||||
$nativeStarted = $false
|
||||
try {
|
||||
# Exclusive receipt reservation also serializes accidental concurrent invocations.
|
||||
$nativeReceiptStream = Open-NativeOutput $nativeReceipt
|
||||
Write-NativeRecord $nativeState $nativeReceiptStream
|
||||
$nativeOutStream = Open-NativeOutput $nativeStdout
|
||||
$nativeErrStream = Open-NativeOutput $nativeStderr
|
||||
$nativeInfo = [Diagnostics.ProcessStartInfo]::new()
|
||||
$nativeInfo.FileName = $nativeGui.path
|
||||
$nativeInfo.WorkingDirectory = $nativeCwd
|
||||
$nativeInfo.UseShellExecute = $false
|
||||
$nativeInfo.CreateNoWindow = $true
|
||||
$nativeInfo.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden
|
||||
$nativeInfo.RedirectStandardOutput = $true
|
||||
$nativeInfo.RedirectStandardError = $true
|
||||
$nativeInfo.Environment['PATH'] = $nativePath
|
||||
$nativeInfo.Environment['LOCALAPPDATA'] = $nativeProfile
|
||||
foreach ($file in @($nativeGui, $nativeDll, $nativeManifestFile, $nativeInputs, $nativeBuildFile)) {
|
||||
if ((Read-NativeFile $file.path).sha256 -ne $file.sha256) { throw "Launch-critical input changed: $($file.path)" }
|
||||
}
|
||||
$nativeProcess = [Diagnostics.Process]::new()
|
||||
$nativeProcess.StartInfo = $nativeInfo
|
||||
$nativeState.launch_requested_utc = [DateTime]::UtcNow.ToString('o')
|
||||
if (-not $nativeProcess.Start()) { throw 'Process.Start returned false.' }
|
||||
$nativeStarted = $true
|
||||
$nativeState.pid = $nativeProcess.Id
|
||||
# Pump both raw streams concurrently; no line decoding/newline normalization.
|
||||
$nativeOutCopy = $nativeProcess.StandardOutput.BaseStream.CopyToAsync($nativeOutStream)
|
||||
$nativeErrCopy = $nativeProcess.StandardError.BaseStream.CopyToAsync($nativeErrStream)
|
||||
$nativeState.process_started_utc = $nativeProcess.StartTime.ToUniversalTime().ToString('o')
|
||||
$nativeState.state = 'launched'
|
||||
Write-NativeRecord $nativeState $nativeReceiptStream
|
||||
$nativeReceiptStream.Dispose()
|
||||
$nativeReceiptStream = $null
|
||||
$nativeState | ConvertTo-Json -Depth 12 | Write-Output
|
||||
# Deliberately stays attached as a logger. Root uses a running shell session and closes GUI normally.
|
||||
$nativeProcess.WaitForExit()
|
||||
[Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($nativeOutCopy, $nativeErrCopy))
|
||||
$nativeOutStream.Flush($true)
|
||||
$nativeErrStream.Flush($true)
|
||||
$nativeOutStream.Dispose()
|
||||
$nativeOutStream = $null
|
||||
$nativeErrStream.Dispose()
|
||||
$nativeErrStream = $null
|
||||
$nativeClosed = [ordered]@{
|
||||
source_commit = $nativeFreeze
|
||||
pid = $nativeState.pid
|
||||
process_started_utc = $nativeState.process_started_utc
|
||||
observed_exit_utc = [DateTime]::UtcNow.ToString('o')
|
||||
exit_code = $nativeProcess.ExitCode
|
||||
scope = 'Process termination and closed raw logs only; UI acceptance and reason for close require root observation.'
|
||||
stdout = (Read-NativeFile $nativeStdout)
|
||||
stderr = (Read-NativeFile $nativeStderr)
|
||||
studio_readback = (Read-NativeFile $nativeGui.path)
|
||||
runtime_readback = (Read-NativeFile $nativeDll.path)
|
||||
}
|
||||
$nativeCloseStream = Open-NativeOutput $nativeTerminal
|
||||
try { Write-NativeRecord $nativeClosed $nativeCloseStream } finally { $nativeCloseStream.Dispose() }
|
||||
$nativeClosed | ConvertTo-Json -Depth 12 | Write-Output
|
||||
} catch {
|
||||
if ($null -ne $nativeReceiptStream) {
|
||||
$nativeState.state = if ($nativeStarted) { 'launched_record_error' } else { 'launch_failed' }
|
||||
$nativeState['error'] = $_.Exception.Message
|
||||
Write-NativeRecord $nativeState $nativeReceiptStream
|
||||
}
|
||||
throw
|
||||
} finally {
|
||||
if ($null -ne $nativeReceiptStream) { $nativeReceiptStream.Dispose() }
|
||||
if ($null -ne $nativeOutStream) { $nativeOutStream.Dispose() }
|
||||
if ($null -ne $nativeErrStream) { $nativeErrStream.Dispose() }
|
||||
if ($null -ne $nativeProcess) { $nativeProcess.Dispose() }
|
||||
# Never kill an application here. Root owns every native interaction and any recovery.
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"scope": "Parser only; no launch",
|
||||
"errors": [],
|
||||
"pass": true
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"source_commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
|
||||
"scope": "Independent pre-grapheme a57 camera/clipping/zoom/hit session; no package or new combined Unicode acceptance implied.",
|
||||
"preparation_manifest": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\preparation-manifest.json",
|
||||
"bytes": 9149,
|
||||
"sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB"
|
||||
},
|
||||
"build_inputs_manifest": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\inputs.json",
|
||||
"bytes": 101859,
|
||||
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
|
||||
},
|
||||
"build_status_file": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\studio-build-status.json",
|
||||
"bytes": 474,
|
||||
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
|
||||
},
|
||||
"build_status": {
|
||||
"seconds": 385.1131455,
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\studio-build.log",
|
||||
"started_utc": "2026-09-11T20:14:23.6348444Z",
|
||||
"exit": 0,
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--bin",
|
||||
"arcadia-studio",
|
||||
"--offline",
|
||||
"--locked"
|
||||
],
|
||||
"name": "canvas-union-windows-20260911/studio-build",
|
||||
"completed_utc": "2026-09-11T20:20:48.7677661Z"
|
||||
},
|
||||
"studio": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\arcadia-studio.exe",
|
||||
"bytes": 113028608,
|
||||
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
|
||||
},
|
||||
"runtime": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\libunwind.dll",
|
||||
"bytes": 190464,
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
|
||||
},
|
||||
"launch_script": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\launch-native-a57c334-camera.ps1",
|
||||
"bytes": 10001,
|
||||
"sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE"
|
||||
},
|
||||
"process_path": "C:\\Windows\\System32;C:\\Windows;C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin",
|
||||
"isolated_localappdata": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\profile",
|
||||
"working_directory": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\unrelated-cwd",
|
||||
"arguments": [],
|
||||
"environment_scope": "Child ProcessStartInfo only; other normal inherited environment retained.",
|
||||
"window_style": "Hidden",
|
||||
"use_shell_execute": false,
|
||||
"create_no_window": true,
|
||||
"stdout": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stdout.log",
|
||||
"stderr": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stderr.log",
|
||||
"pid": 5004,
|
||||
"launch_requested_utc": "2026-09-12T10:52:56.9766659Z",
|
||||
"process_started_utc": "2026-09-12T10:52:57.0962185Z",
|
||||
"state": "launched",
|
||||
"acceptance": "Pending root observation and independent source/model checks."
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
PASS same: all identities, fields, reachable source and physical bytes
|
||||
@@ -1,347 +0,0 @@
|
||||
{
|
||||
"created_utc": "2026-09-12T11:07:20.1744786Z",
|
||||
"scope": "Closed a57 camera evidence only; copying does not execute gates or UI",
|
||||
"payload_count": 47,
|
||||
"payload_bytes": 2184242.0,
|
||||
"files": [
|
||||
{
|
||||
"path": "baseline-wide/project.arcadia",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\baseline-wide\\project.arcadia",
|
||||
"bytes": 20128,
|
||||
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"path": "baseline-wide/rationale.arcadia",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\baseline-wide\\rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"path": "checkpoints/wide-passive/project.arcadia",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\checkpoints\\wide-passive\\project.arcadia",
|
||||
"bytes": 20128,
|
||||
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"path": "checkpoints/wide-passive/rationale.arcadia",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\checkpoints\\wide-passive\\rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"path": "launch-script-syntax.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\launch-script-syntax.json",
|
||||
"bytes": 77,
|
||||
"original_sha256": "9FE1369737B8A02EE9DDB83318CE800F200AFABF5486C9DD6F52DBEC9F87502C",
|
||||
"copy_sha256": "9FE1369737B8A02EE9DDB83318CE800F200AFABF5486C9DD6F52DBEC9F87502C"
|
||||
},
|
||||
{
|
||||
"path": "launch.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\launch.json",
|
||||
"bytes": 3308,
|
||||
"original_sha256": "7BCF09DFB689DB4E686BBACD0F1805B013597FB4159D5477D5DE56F52D79D147",
|
||||
"copy_sha256": "7BCF09DFB689DB4E686BBACD0F1805B013597FB4159D5477D5DE56F52D79D147"
|
||||
},
|
||||
{
|
||||
"path": "logs/studio.stderr.log",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stderr.log",
|
||||
"bytes": 0,
|
||||
"original_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"copy_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
{
|
||||
"path": "logs/studio.stdout.log",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stdout.log",
|
||||
"bytes": 0,
|
||||
"original_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"copy_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
{
|
||||
"path": "logs/wide-passive.stderr.log",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\wide-passive.stderr.log",
|
||||
"bytes": 0,
|
||||
"original_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"copy_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
{
|
||||
"path": "logs/wide-passive.stdout.log",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\wide-passive.stdout.log",
|
||||
"bytes": 71,
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
|
||||
},
|
||||
{
|
||||
"path": "normal-close.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\normal-close.json",
|
||||
"bytes": 1326,
|
||||
"original_sha256": "21CE20643ED69CA1247B261A44A71A562850D0BCF8339761E60F11B7A19A7AF1",
|
||||
"copy_sha256": "21CE20643ED69CA1247B261A44A71A562850D0BCF8339761E60F11B7A19A7AF1"
|
||||
},
|
||||
{
|
||||
"path": "preparation-manifest.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\preparation-manifest.json",
|
||||
"bytes": 9149,
|
||||
"original_sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB",
|
||||
"copy_sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB"
|
||||
},
|
||||
{
|
||||
"path": "prepare-inputs.py",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\prepare-inputs.py",
|
||||
"bytes": 5563,
|
||||
"original_sha256": "661D6686AD0B59FBD28CB4FE42CFD4955DB5922B9CE3B5EF3F378A4CF1982884",
|
||||
"copy_sha256": "661D6686AD0B59FBD28CB4FE42CFD4955DB5922B9CE3B5EF3F378A4CF1982884"
|
||||
},
|
||||
{
|
||||
"path": "provenance/after-build-verify-head.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\after-build-verify-head.json",
|
||||
"bytes": 199,
|
||||
"original_sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08",
|
||||
"copy_sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08"
|
||||
},
|
||||
{
|
||||
"path": "provenance/binary-manifest.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\binary-manifest.json",
|
||||
"bytes": 2828,
|
||||
"original_sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA",
|
||||
"copy_sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA"
|
||||
},
|
||||
{
|
||||
"path": "provenance/desktop-all-targets-status.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\desktop-all-targets-status.json",
|
||||
"bytes": 472,
|
||||
"original_sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0",
|
||||
"copy_sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0"
|
||||
},
|
||||
{
|
||||
"path": "provenance/desktop-clippy-status.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\desktop-clippy-status.json",
|
||||
"bytes": 503,
|
||||
"original_sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C",
|
||||
"copy_sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C"
|
||||
},
|
||||
{
|
||||
"path": "provenance/focused-lifecycle-status.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\focused-lifecycle-status.json",
|
||||
"bytes": 509,
|
||||
"original_sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3",
|
||||
"copy_sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3"
|
||||
},
|
||||
{
|
||||
"path": "provenance/inputs.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\inputs.json",
|
||||
"bytes": 101859,
|
||||
"original_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"copy_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-fixture-README.md",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\lifecycle-fixture-README.md",
|
||||
"bytes": 12199,
|
||||
"original_sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78",
|
||||
"copy_sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78"
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-native-contract.rs",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\lifecycle-native-contract.rs",
|
||||
"bytes": 19135,
|
||||
"original_sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4",
|
||||
"copy_sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4"
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-oracle-status.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\lifecycle-oracle-status.json",
|
||||
"bytes": 1272,
|
||||
"original_sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C",
|
||||
"copy_sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C"
|
||||
},
|
||||
{
|
||||
"path": "provenance/oracles-865-copy-manifest.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\oracles-865-copy-manifest.json",
|
||||
"bytes": 1015,
|
||||
"original_sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C",
|
||||
"copy_sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C"
|
||||
},
|
||||
{
|
||||
"path": "provenance/studio-build-status.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\studio-build-status.json",
|
||||
"bytes": 474,
|
||||
"original_sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC",
|
||||
"copy_sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
|
||||
},
|
||||
{
|
||||
"path": "provenance/typography-admission.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\typography-admission.json",
|
||||
"bytes": 553,
|
||||
"original_sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8",
|
||||
"copy_sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8"
|
||||
},
|
||||
{
|
||||
"path": "provenance/typography-predictions.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\typography-predictions.json",
|
||||
"bytes": 2490,
|
||||
"original_sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243",
|
||||
"copy_sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/01-wide-opened.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\01-wide-opened.jpg",
|
||||
"bytes": 102087,
|
||||
"original_sha256": "76B5ABF72AAA2B40DB91FCF182E3B464BEE29CC9ABDCCC4005EC9567554C7E18",
|
||||
"copy_sha256": "76B5ABF72AAA2B40DB91FCF182E3B464BEE29CC9ABDCCC4005EC9567554C7E18"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/02-behavior-open.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\02-behavior-open.jpg",
|
||||
"bytes": 118609,
|
||||
"original_sha256": "92D75BCF44D78221D99C393C039E6ED395AF70973477643EEF437CD0B5EFF1A2",
|
||||
"copy_sha256": "92D75BCF44D78221D99C393C039E6ED395AF70973477643EEF437CD0B5EFF1A2"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/03-oa-create-selected-100.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\03-oa-create-selected-100.jpg",
|
||||
"bytes": 134137,
|
||||
"original_sha256": "0028B918C56C5686890C18D730D3394CC07A8D0714DBD5175B06A336C2BD937C",
|
||||
"copy_sha256": "0028B918C56C5686890C18D730D3394CC07A8D0714DBD5175B06A336C2BD937C"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/04-oa-create-zoom125.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\04-oa-create-zoom125.jpg",
|
||||
"bytes": 131774,
|
||||
"original_sha256": "9B6627543E8A79E8D217029861407A89C884F36A3024BE44F5B8205AD2B557ED",
|
||||
"copy_sha256": "9B6627543E8A79E8D217029861407A89C884F36A3024BE44F5B8205AD2B557ED"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/05-oa-delete-selected-125.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\05-oa-delete-selected-125.jpg",
|
||||
"bytes": 132774,
|
||||
"original_sha256": "1BC820418F12B18EBC97F52D2B2B85B8B9BDD57F9485BE040946FC9CC2F37FAA",
|
||||
"copy_sha256": "1BC820418F12B18EBC97F52D2B2B85B8B9BDD57F9485BE040946FC9CC2F37FAA"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/06-oa-fit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\06-oa-fit.jpg",
|
||||
"bytes": 133100,
|
||||
"original_sha256": "585063787D8E0299A7C2173BB8EC0264CDF51D5EDD8CBC3FCC953EF1917E1116",
|
||||
"copy_sha256": "585063787D8E0299A7C2173BB8EC0264CDF51D5EDD8CBC3FCC953EF1917E1116"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/07-minimum-width.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\07-minimum-width.jpg",
|
||||
"bytes": 123533,
|
||||
"original_sha256": "9D798D0B087C7F05FAFDC23237BD934B069B69E1A08D8C33EAF74D4E85264582",
|
||||
"copy_sha256": "9D798D0B087C7F05FAFDC23237BD934B069B69E1A08D8C33EAF74D4E85264582"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/08-minimum-window.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\08-minimum-window.jpg",
|
||||
"bytes": 105379,
|
||||
"original_sha256": "DFBAFFA375445BCD36565BDC8873DBF457E0A528A827E882F069B95D90529944",
|
||||
"copy_sha256": "DFBAFFA375445BCD36565BDC8873DBF457E0A528A827E882F069B95D90529944"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/09-oa-minimum-fit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\09-oa-minimum-fit.jpg",
|
||||
"bytes": 99033,
|
||||
"original_sha256": "FD2FEE1D602488243876B6FAEAFCBBCFE7065DDCFA2C28AD743A0FD19673C4B9",
|
||||
"copy_sha256": "FD2FEE1D602488243876B6FAEAFCBBCFE7065DDCFA2C28AD743A0FD19673C4B9"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/10-oa-minimum-create-hit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\10-oa-minimum-create-hit.jpg",
|
||||
"bytes": 99898,
|
||||
"original_sha256": "7975C21C9E41D32A8684E3420DEF616D7AC1E43F5EF291E94380FA63DA37241F",
|
||||
"copy_sha256": "7975C21C9E41D32A8684E3420DEF616D7AC1E43F5EF291E94380FA63DA37241F"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/11-oa-minimum-pan-right.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\11-oa-minimum-pan-right.jpg",
|
||||
"bytes": 99456,
|
||||
"original_sha256": "61BD5C917B738E1C3B2267D623A41193A8C5F84F5A1F7B51DAFA1B147DB13A51",
|
||||
"copy_sha256": "61BD5C917B738E1C3B2267D623A41193A8C5F84F5A1F7B51DAFA1B147DB13A51"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/12-oa-panned-delete-hit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\12-oa-panned-delete-hit.jpg",
|
||||
"bytes": 98952,
|
||||
"original_sha256": "1B9C0E408BC9445AE0E6BDC2F86EA341AA385EB26D42693C76309963D9D4064C",
|
||||
"copy_sha256": "1B9C0E408BC9445AE0E6BDC2F86EA341AA385EB26D42693C76309963D9D4064C"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/13-system-layer.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\13-system-layer.jpg",
|
||||
"bytes": 101904,
|
||||
"original_sha256": "02992B658775E55F0CBAF974050BBFEA0C1F608799C5D9BE9A05AA6789C9F1F4",
|
||||
"copy_sha256": "02992B658775E55F0CBAF974050BBFEA0C1F608799C5D9BE9A05AA6789C9F1F4"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/14-sa-minimum-fit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\14-sa-minimum-fit.jpg",
|
||||
"bytes": 95469,
|
||||
"original_sha256": "E4A274CA8DA7ECD2255EF519C8FC81D8310DB55C9DB9009FC8731751A25202CB",
|
||||
"copy_sha256": "E4A274CA8DA7ECD2255EF519C8FC81D8310DB55C9DB9009FC8731751A25202CB"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/15-sa-minimum-create-hit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\15-sa-minimum-create-hit.jpg",
|
||||
"bytes": 99837,
|
||||
"original_sha256": "4ED02839A417EE567CD8F23750DFFB535AF8C32063B535F8C0B4BE8ACB082542",
|
||||
"copy_sha256": "4ED02839A417EE567CD8F23750DFFB535AF8C32063B535F8C0B4BE8ACB082542"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/16-sa-pan-up.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\16-sa-pan-up.jpg",
|
||||
"bytes": 96836,
|
||||
"original_sha256": "26048071F3B2A09ED054D18834C128093CC1FB9B1C4A7518B09E0F9E9EC8291A",
|
||||
"copy_sha256": "26048071F3B2A09ED054D18834C128093CC1FB9B1C4A7518B09E0F9E9EC8291A"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/17-sa-panned-delete-hit.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\17-sa-panned-delete-hit.jpg",
|
||||
"bytes": 96499,
|
||||
"original_sha256": "31EA2A24959B4A5014C9FCD0E43BD918847C6FB58EB0B8B1577C56074E266BC0",
|
||||
"copy_sha256": "31EA2A24959B4A5014C9FCD0E43BD918847C6FB58EB0B8B1577C56074E266BC0"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/18-passive-navigation-saved.jpg",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\18-passive-navigation-saved.jpg",
|
||||
"bytes": 94345,
|
||||
"original_sha256": "280942F9B9806884D4EE34A5FB740428A420D7603EDDDF21D6E4979BEE041B2D",
|
||||
"copy_sha256": "280942F9B9806884D4EE34A5FB740428A420D7603EDDDF21D6E4979BEE041B2D"
|
||||
},
|
||||
{
|
||||
"path": "second-readback.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\second-readback.json",
|
||||
"bytes": 6332,
|
||||
"original_sha256": "BB8063AAC3FFF3A7B8FC19BA81F9EBBC13068871F6924651A14B08D58CE525BB",
|
||||
"copy_sha256": "BB8063AAC3FFF3A7B8FC19BA81F9EBBC13068871F6924651A14B08D58CE525BB"
|
||||
},
|
||||
{
|
||||
"path": "wide-passive-status.json",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\wide-passive-status.json",
|
||||
"bytes": 857,
|
||||
"original_sha256": "A358FA3C26A11DEACFC9457EC96F27F43EBD9020575329E701F0F026759850BF",
|
||||
"copy_sha256": "A358FA3C26A11DEACFC9457EC96F27F43EBD9020575329E701F0F026759850BF"
|
||||
},
|
||||
{
|
||||
"path": "launch-native-a57c334-camera.ps1",
|
||||
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\launch-native-a57c334-camera.ps1",
|
||||
"bytes": 10001,
|
||||
"original_sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE",
|
||||
"copy_sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE"
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
"bin",
|
||||
"oracles executables/DLL",
|
||||
"baseline-clusters",
|
||||
"working-wide",
|
||||
"working-clusters",
|
||||
"profile",
|
||||
"unrelated-cwd",
|
||||
"ZIP archives"
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"source_commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
|
||||
"pid": 5004,
|
||||
"process_started_utc": "2026-09-12T10:52:57.0962185Z",
|
||||
"observed_exit_utc": "2026-09-12T11:02:56.0410831Z",
|
||||
"exit_code": 0,
|
||||
"scope": "Process termination and closed raw logs only; UI acceptance and reason for close require root observation.",
|
||||
"stdout": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stdout.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
"stderr": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
"studio_readback": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\arcadia-studio.exe",
|
||||
"bytes": 113028608,
|
||||
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
|
||||
},
|
||||
"runtime_readback": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\libunwind.dll",
|
||||
"bytes": 190464,
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"prepared_utc": "2026-09-11T21:32:45.779806+00:00",
|
||||
"source_commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
|
||||
"scope": "Preparation only for independent OA/SA camera, clipping, minimum-size, zoom and hit checks with passive Save equality. Pre-grapheme a57; no package or new combined Unicode acceptance.",
|
||||
"archive": {
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip",
|
||||
"sha256": "E2C8BB3CC8EB2BFD6ABB0A2C5769756148D594CE0CDC90449A894AB0CB026927"
|
||||
},
|
||||
"input_manifest_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"frozen_input_count": 529,
|
||||
"files": [
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/rationale.arcadia",
|
||||
"target": "baseline-wide/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/rationale.arcadia",
|
||||
"target": "working-wide/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/project.arcadia",
|
||||
"target": "baseline-wide/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/project.arcadia",
|
||||
"target": "working-wide/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/project.arcadia",
|
||||
"target": "baseline-clusters/project.arcadia",
|
||||
"bytes": 20110,
|
||||
"sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/project.arcadia",
|
||||
"target": "working-clusters/project.arcadia",
|
||||
"bytes": 20110,
|
||||
"sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/rationale.arcadia",
|
||||
"target": "baseline-clusters/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/rationale.arcadia",
|
||||
"target": "working-clusters/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/README.md",
|
||||
"target": "provenance/lifecycle-fixture-README.md",
|
||||
"bytes": 12199,
|
||||
"sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\bin\\arcadia-studio.exe",
|
||||
"target": "bin/arcadia-studio.exe",
|
||||
"bytes": 113028608,
|
||||
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\bin\\libunwind.dll",
|
||||
"target": "bin/libunwind.dll",
|
||||
"bytes": 190464,
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\inputs.json",
|
||||
"target": "provenance/inputs.json",
|
||||
"bytes": 101859,
|
||||
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\binary-manifest.json",
|
||||
"target": "provenance/binary-manifest.json",
|
||||
"bytes": 2828,
|
||||
"sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\after-build-verify-head.json",
|
||||
"target": "provenance/after-build-verify-head.json",
|
||||
"bytes": 199,
|
||||
"sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\studio-build-status.json",
|
||||
"target": "provenance/studio-build-status.json",
|
||||
"bytes": 474,
|
||||
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-clippy-status.json",
|
||||
"target": "provenance/desktop-clippy-status.json",
|
||||
"bytes": 503,
|
||||
"sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-all-targets-status.json",
|
||||
"target": "provenance/desktop-all-targets-status.json",
|
||||
"bytes": 472,
|
||||
"sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\focused-lifecycle-status.json",
|
||||
"target": "provenance/focused-lifecycle-status.json",
|
||||
"bytes": 509,
|
||||
"sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\oracles\\lifecycle.exe",
|
||||
"target": "oracles/lifecycle.exe",
|
||||
"bytes": 20700160,
|
||||
"sha256": "798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\oracles\\libunwind.dll",
|
||||
"target": "oracles/libunwind.dll",
|
||||
"bytes": 190464,
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\harness\\lifecycle-native-contract.rs",
|
||||
"target": "provenance/lifecycle-native-contract.rs",
|
||||
"bytes": 19135,
|
||||
"sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\oracles\\manifest.json",
|
||||
"target": "provenance/oracles-865-copy-manifest.json",
|
||||
"bytes": 1015,
|
||||
"sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\lifecycle-native-ytr83r-20260911\\status.json",
|
||||
"target": "provenance/lifecycle-oracle-status.json",
|
||||
"bytes": 1272,
|
||||
"sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\harness\\experiments\\lifecycle-native-ytr83r-20260911\\typography-admission.json",
|
||||
"target": "provenance/typography-admission.json",
|
||||
"bytes": 553,
|
||||
"sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8"
|
||||
},
|
||||
{
|
||||
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\harness\\experiments\\lifecycle-native-ytr83r-20260911\\typography-predictions.json",
|
||||
"target": "provenance/typography-predictions.json",
|
||||
"bytes": 2490,
|
||||
"sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243"
|
||||
}
|
||||
],
|
||||
"empty_profile": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\profile",
|
||||
"unrelated_cwd": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\unrelated-cwd",
|
||||
"restricted_path": "C:\\Windows\\System32;C:\\Windows;C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin"
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Preparation only: copy and hash frozen inputs; never execute model/oracle/GUI code."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
repo = root.parent.parent
|
||||
gates = repo / '.local/canvas-union-windows-20260911'
|
||||
freeze = 'a57c334b302eb13352b4bb65df1c75d94d002348'
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest().upper()
|
||||
|
||||
def write_new(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open('xb') as out:
|
||||
out.write(data)
|
||||
|
||||
records = []
|
||||
def copy_exact(source, target, expected=None):
|
||||
source = Path(source)
|
||||
data = source.read_bytes()
|
||||
sha = digest(data)
|
||||
if expected:
|
||||
assert sha == expected.upper(), (source, sha, expected)
|
||||
destination = root / target
|
||||
write_new(destination, data)
|
||||
assert destination.read_bytes() == data == source.read_bytes()
|
||||
records.append(dict(origin=str(source), target=target, bytes=len(data), sha256=sha))
|
||||
|
||||
assert not (root / 'preparation-manifest.json').exists(), 'Never overwrite a prior preparation'
|
||||
manifest_bytes = (gates / 'inputs.json').read_bytes()
|
||||
assert digest(manifest_bytes) == '47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3'
|
||||
inputs = json.loads(manifest_bytes)
|
||||
indexed = {item['path']: item for item in inputs['files']}
|
||||
assert len(indexed) == 529
|
||||
archive = gates / 'exact-inputs.zip'
|
||||
archive_sha = digest(archive.read_bytes())
|
||||
with zipfile.ZipFile(archive) as frozen:
|
||||
selected = {
|
||||
'harness/fixtures/scenario-lifecycle-native/typography-wide/rationale.arcadia': ('baseline-wide/rationale.arcadia', 'working-wide/rationale.arcadia'),
|
||||
'harness/fixtures/scenario-lifecycle-native/typography-wide/project.arcadia': ('baseline-wide/project.arcadia', 'working-wide/project.arcadia'),
|
||||
'harness/fixtures/scenario-lifecycle-native/typography-clusters/project.arcadia': ('baseline-clusters/project.arcadia', 'working-clusters/project.arcadia'),
|
||||
'harness/fixtures/scenario-lifecycle-native/typography-clusters/rationale.arcadia': ('baseline-clusters/rationale.arcadia', 'working-clusters/rationale.arcadia'),
|
||||
'harness/fixtures/scenario-lifecycle-native/README.md': ('provenance/lifecycle-fixture-README.md',),
|
||||
}
|
||||
for name, destinations in selected.items():
|
||||
data = frozen.read(name)
|
||||
record = indexed[name]
|
||||
assert len(data) == record['bytes'] and digest(data) == record['sha256'].upper(), name
|
||||
for destination in destinations:
|
||||
write_new(root / destination, data)
|
||||
records.append(dict(origin=str(archive) + '::' + name, target=destination, bytes=len(data), sha256=digest(data)))
|
||||
|
||||
copy_exact(gates / 'bin/arcadia-studio.exe', 'bin/arcadia-studio.exe', '69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40')
|
||||
copy_exact(gates / 'bin/libunwind.dll', 'bin/libunwind.dll', 'AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A')
|
||||
for name in ('inputs.json', 'binary-manifest.json', 'after-build-verify-head.json', 'studio-build-status.json', 'desktop-clippy-status.json', 'desktop-all-targets-status.json', 'focused-lifecycle-status.json'):
|
||||
copy_exact(gates / name, 'provenance/' + name)
|
||||
for source, target, expected in (
|
||||
('.local/native-865483b/oracles/lifecycle.exe', 'oracles/lifecycle.exe', '798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524'),
|
||||
('.local/native-865483b/oracles/libunwind.dll', 'oracles/libunwind.dll', 'AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A'),
|
||||
('harness/lifecycle-native-contract.rs', 'provenance/lifecycle-native-contract.rs', 'FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4'),
|
||||
):
|
||||
copy_exact(repo / source, target, expected)
|
||||
for source, target in (
|
||||
('.local/native-865483b/oracles/manifest.json', 'provenance/oracles-865-copy-manifest.json'),
|
||||
('.local/lifecycle-native-ytr83r-20260911/status.json', 'provenance/lifecycle-oracle-status.json'),
|
||||
('harness/experiments/lifecycle-native-ytr83r-20260911/typography-admission.json', 'provenance/typography-admission.json'),
|
||||
('harness/experiments/lifecycle-native-ytr83r-20260911/typography-predictions.json', 'provenance/typography-predictions.json'),
|
||||
):
|
||||
copy_exact(repo / source, target)
|
||||
|
||||
for directory in ('profile', 'unrelated-cwd', 'logs', 'screenshots', 'checkpoints'):
|
||||
(root / directory).mkdir(exist_ok=False)
|
||||
for record in records:
|
||||
if record['target'].startswith(('baseline-', 'bin/', 'oracles/', 'provenance/')):
|
||||
(root / record['target']).chmod(0o444)
|
||||
result = dict(prepared_utc=datetime.now(timezone.utc).isoformat(), source_commit=freeze,
|
||||
scope='Preparation only for independent OA/SA camera, clipping, minimum-size, zoom and hit checks with passive Save equality. Pre-grapheme a57; no package or new combined Unicode acceptance.',
|
||||
archive=dict(path=str(archive), sha256=archive_sha), input_manifest_sha256=digest(manifest_bytes),
|
||||
frozen_input_count=529, files=records,
|
||||
empty_profile=str(root / 'profile'), unrelated_cwd=str(root / 'unrelated-cwd'),
|
||||
restricted_path='C:\\Windows\\System32;C:\\Windows;' + str(root / 'bin'))
|
||||
write_new(root / 'preparation-manifest.json', (json.dumps(result, indent=2) + '\n').encode())
|
||||
print(json.dumps(dict(files=len(records), copied_bytes=sum(r['bytes'] for r in records), archive_sha256=archive_sha), indent=2))
|
||||
@@ -1 +0,0 @@
|
||||
{"mode": "verify-head", "utc": "2026-09-11T20:20:58.775988+00:00", "files": 529, "bytes": 9712849, "manifest_sha256": "47fec9cadc0e7fae99d789376c111c02eeaab7fac3740de48174a06f61fe16b3", "pass": true}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"worktree": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-ytr83r-20260911",
|
||||
"scope": "Windows source/build gates and immutable copies only; no native launch or package acceptance",
|
||||
"source_commit": "a57c334 (root-reported; all 529 frozen inputs independently verified against worktree HEAD)",
|
||||
"verified_inputs": 529,
|
||||
"input_manifest_sha256": "47fec9cadc0e7fae99d789376c111c02eeaab7fac3740de48174a06f61fe16b3",
|
||||
"binaries": [
|
||||
{
|
||||
"read_only": true,
|
||||
"copy": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\bin\\arcadia-studio.exe",
|
||||
"copy_sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40",
|
||||
"source_after_sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40",
|
||||
"source": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\target\\debug\\arcadia-studio.exe",
|
||||
"bytes": 113028608,
|
||||
"source_sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
|
||||
},
|
||||
{
|
||||
"read_only": true,
|
||||
"copy": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\bin\\libunwind.dll",
|
||||
"copy_sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A",
|
||||
"source_after_sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A",
|
||||
"source": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\llvm-mingw-20260908-ucrt-x86_64\\bin\\libunwind.dll",
|
||||
"bytes": 190464,
|
||||
"source_sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
|
||||
}
|
||||
],
|
||||
"clippy": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--offline",
|
||||
"--locked",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"completed_utc": "2026-09-11T20:13:49.0390005Z",
|
||||
"name": "canvas-union-windows-20260911/desktop-clippy",
|
||||
"exit": 0,
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-clippy.log",
|
||||
"started_utc": "2026-09-11T20:08:57.0977732Z",
|
||||
"seconds": 291.9567566
|
||||
},
|
||||
"build": {
|
||||
"seconds": 385.1131455,
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\studio-build.log",
|
||||
"started_utc": "2026-09-11T20:14:23.6348444Z",
|
||||
"exit": 0,
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--bin",
|
||||
"arcadia-studio",
|
||||
"--offline",
|
||||
"--locked"
|
||||
],
|
||||
"name": "canvas-union-windows-20260911/studio-build",
|
||||
"completed_utc": "2026-09-11T20:20:48.7677661Z"
|
||||
},
|
||||
"verified_input_bytes": 9712849,
|
||||
"preserved_utc": "2026-09-11T20:21:10.0070190Z"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-all-targets.log",
|
||||
"completed_utc": "2026-09-11T20:06:06.1170595Z",
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--offline",
|
||||
"--locked"
|
||||
],
|
||||
"seconds": 565.0596711,
|
||||
"name": "canvas-union-windows-20260911/desktop-all-targets",
|
||||
"exit": 0,
|
||||
"started_utc": "2026-09-11T19:56:40.8624918Z"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--offline",
|
||||
"--locked",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"completed_utc": "2026-09-11T20:13:49.0390005Z",
|
||||
"name": "canvas-union-windows-20260911/desktop-clippy",
|
||||
"exit": 0,
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-clippy.log",
|
||||
"started_utc": "2026-09-11T20:08:57.0977732Z",
|
||||
"seconds": 291.9567566
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\focused-lifecycle.log",
|
||||
"completed_utc": "2026-09-11T19:55:46.4531913Z",
|
||||
"started_utc": "2026-09-11T19:42:20.3858323Z",
|
||||
"seconds": 806.0877483,
|
||||
"name": "canvas-union-windows-20260911/focused-lifecycle",
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--offline",
|
||||
"--locked",
|
||||
"scenario_lifecycle",
|
||||
"--",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"exit": 0
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
# Independent native lifecycle fixture — 2026-09-11
|
||||
|
||||
Owner: Codex-ytr83r /root/integration_audit. **Core/source admission passed; native
|
||||
execution remains pending.** Root compiled the checker, passed its self-check and
|
||||
generated/checked all three immutable expectations before opening Studio. No native
|
||||
or Scene acceptance is claimed here. The renderer implementation is independently
|
||||
owned by /root/server_audit; this oracle does not import any desktop module.
|
||||
|
||||
## Build and source admission
|
||||
|
||||
Compile `harness/lifecycle-native-contract.rs` with the matching `oa_core` rlib and
|
||||
dependency search directory, Rust2024 and warnings denied. No separate serde_json
|
||||
dependency is needed. Root owns the writer/toolchain lease. Example shape:
|
||||
|
||||
```text
|
||||
rustc --edition=2024 -D warnings harness/lifecycle-native-contract.rs --extern oa_core=ABSOLUTE_MATCHING_RLIB -L dependency=ABSOLUTE_DEPS -o ABSOLUTE_ISOLATED_EXE
|
||||
lifecycle-native-contract.exe self-check
|
||||
lifecycle-native-contract.exe inventory
|
||||
lifecycle-native-contract.exe generate baseline FRESH_BASE_DIR
|
||||
lifecycle-native-contract.exe check baseline FRESH_BASE_DIR/project.arcadia
|
||||
lifecycle-native-contract.exe generate oa-rename FRESH_EXPECTED_OA_DIR
|
||||
lifecycle-native-contract.exe generate sa-rename FRESH_EXPECTED_SA_DIR
|
||||
```
|
||||
|
||||
Keep logs, screenshot files, hashes and model exports **outside** each checkpoint
|
||||
directory. A checkpoint contains only its complete saved project files; the checker
|
||||
compares every physical file, not just files with `.arcadia` extension. Generated
|
||||
base is two sources: canonical project plus an intentionally CRLF Unicode note.
|
||||
The two expected rename directories must be generated before native output is read.
|
||||
All element/relation IDs are fixed; SA port UUIDv5 values derive from the fixed
|
||||
functional-exchange ID through the authoritative port planner. No observed identity,
|
||||
geometry, order, reference or name is borrowed into predictions.
|
||||
|
||||
The unchecked fixture builder is only preparation: final `execute_batch`, source
|
||||
compile and load/recompile equality are mandatory. Self-Delete and unassigned
|
||||
lifeline cases intentionally use explicit local sender/receiver with `exchange=None`.
|
||||
If current core refuses either, record the exact refusal and revise or separate that
|
||||
case under root review; do not bypass admission or silently remove validation.
|
||||
Self-Create is deliberately absent because its admission was not established.
|
||||
Expected unfinished-model warnings (unbound exchange/representation, unanswered
|
||||
Call) are diagnostic evidence, not permission to bypass errors. Capture the actual
|
||||
warning set after source admission.
|
||||
|
||||
## Fixed identities and navigation
|
||||
|
||||
All numeric offsets below are **decimal** added to UUID integer
|
||||
`5ceab400-0000-4000-8000-000000000000`. Project is offset1. Add side base1000 for OA
|
||||
or2000 for SA, then the listed local offset. Names begin OA/SA for explorer search.
|
||||
Each scenario has at most two lanes and explicit OperationalActivity/Functional
|
||||
viewpoint; no hidden legacy scenario mode is used.
|
||||
|
||||
| Scenario local offset/name | Lanes | Occurrences and declared order |
|
||||
| --- | --- | --- |
|
||||
|100 `01 Root create action delete`|101 controller,102 receiver|110 Create receiver →111 Receiver action →112 Delete receiver|
|
||||
|200 `02 Preexisting empty`|201 controller,202 receiver|Empty root|
|
||||
|300 `03 Create only`|301 controller,302 receiver|310 Create only receiver|
|
||||
|400 `04 Conditional delete`|401 controller,402 receiver|410 Open call →411 ALT;412 left operand contains414 Conditional delete;413 right operand empty|
|
||||
|500 `05 Unassigned receiver`|501 controller,502 represented=None|510 Create unassigned receiver →511 Delete unassigned receiver; both exchanges unresolved|
|
||||
|600 `06 Self delete`|601 controller only|610 Self delete controller; sender=receiver601, exchange unresolved|
|
||||
|
||||
Controller/receiver work are local10/11; ordinary service relation is12. Root
|
||||
Delete rename target is side+112. The exact new name is `OA Delete receiver · reviewed`
|
||||
or `SA Delete receiver · reviewed`. Aliases, IDs and every other field stay unchanged.
|
||||
For direct selection, OA root scenario is `5ceab400-0000-4000-8000-00000000044c`
|
||||
and OA Delete is `5ceab400-0000-4000-8000-000000000458`; SA root scenario is
|
||||
`5ceab400-0000-4000-8000-000000000834` and SA Delete is
|
||||
`5ceab400-0000-4000-8000-000000000840`. `inventory` emits the complete admitted
|
||||
scenario/lifeline/occurrence ID/name/parent table and actual validation diagnostics.
|
||||
|
||||
## Native procedure and independent observations
|
||||
|
||||
Use a copied working directory and isolated profile with an immutable matching GUI.
|
||||
Record source commit, executable SHA256, actual client dimensions, input sequence,
|
||||
screenshots and saved checkpoints outside the project directories. Open the actual
|
||||
`project.arcadia` through Studio. Select Behavior & scenarios, the matching layer,
|
||||
and the exact prefixed scenario. First run the OA matrix; repeat SA independently
|
||||
from the same baseline. Neither a passing source checker nor pure Scene tests imply
|
||||
the following visual/interaction results.
|
||||
|
||||
| Step | Native observation/evidence required | Semantic oracle |
|
||||
| --- | --- | --- |
|
||||
|Root lifecycle|Controller remains usable; receiver lifetime begins at Create and ends at Delete; Action lies between. Inspect birth/death hit targets and corresponding Properties identity. Clicking an intended lifecycle target must not select a different occurrence.|Save copied baseline; `check baseline`|
|
||||
|Preexisting empty|Both lane headers/guides visible through the actual empty canvas; no invented birth/death. Neutral insertion guide remains usable even when no occurrence exists.|Save/navigation must remain `baseline`|
|
||||
|Create-only|Receiver lifetime continues beyond the last occurrence to visible root extent; do not confuse insertion endpoint with lifetime endpoint.|`check baseline`|
|
||||
|Conditional Delete|Left receiver activation/lifetime ends at its Delete. Right empty branch retains independent receiver state; Delete must not terminate sibling activation. Inspect unresolved-call indication without inventing Reply or silently rewriting the model.|`check baseline`|
|
||||
|Unassigned receiver|Incomplete lane remains visible, with explicit receiver lifecycle targets/hit geometry. It must not disappear or crash because placement eligibility excludes unresolved representation. Record whatever completeness diagnostic is actually shown.|`check baseline`; only after admitted|
|
||||
|Self Delete|Single lane's self-message end and death marker align and remain selectable. Inspect Properties and hit targets at ordinary100% then Fit; do not infer self-Create behavior.|`check baseline`; only after admitted|
|
||||
|Narrow/overview|At actual1100×700 and a wide client, exercise Fit,100%, scroll, selection and Tools. Guides/markers remain correctly aligned and hit-testable; record font wrapping/overlap and clipped regions.|Save unchanged complete baseline|
|
||||
|Real rename|Select root Delete occurrence by actual marker/message, verify identity, edit Name to exact side-specific reviewed name and Apply, or make precisely that source name edit and Apply. Save once; preserve complete checkpoint before Undo.|`check oa-rename` or `check sa-rename`; exact previously generated expectation via `same`|
|
||||
|Undo|One Undo restores old name, exact IDs and complete source. Save and freeze checkpoint.|`check baseline UNDO_ENTRY RENAMED_ENTRY`, then `same BASE_ENTRY UNDO_ENTRY RENAMED_ENTRY`|
|
||||
|Redo/reopen|One Redo and Save restores exact first rename checkpoint. Reopen through actual file picker and Save again. Observe lifecycle geometry/selection afresh.|`check SIDE-rename`, `same FIRST_RENAME REDO`, `same FIRST_RENAME REOPEN`|
|
||||
|
||||
Each rename mode starts from baseline and changes only its own layer. Undo OA before
|
||||
starting SA or use a fresh baseline copy; `sa-rename` does not include an earlier OA
|
||||
rename. Direct additional scenario insertion is outside these fixed rename modes:
|
||||
define a separate prediction before using it as acceptance evidence.
|
||||
|
||||
## Exact physical-file and Undo policy
|
||||
|
||||
`check MODE ENTRY` independently constructs complete expected Project and reachable
|
||||
SourceBundle, then requires the physical file map to equal those expected sources.
|
||||
`check MODE ENTRY PRE_UNDO_ENTRY` additionally permits only the exact original files
|
||||
present in the explicitly supplied pre-Undo checkpoint. Expected reachable bytes
|
||||
overlay those origin files; missing, unknown or changed retained files fail. No
|
||||
automatic disk-equals-reachable assertion is imposed on a valid Undo with retained
|
||||
files. The explicit origin must be a separately frozen, previously verified accepted
|
||||
checkpoint; do not supply a directory manufactured after seeing an unexpected file.
|
||||
|
||||
`same EXPECTED ACTUAL` pins all identities/model/source and the complete saved physical
|
||||
map of a previously verified checkpoint. Its optional pre-Undo origin uses the same
|
||||
exact retention rule. It never accepts source drift just because decoded models agree.
|
||||
These rename cases normally create no detached files, but the checker explicitly
|
||||
self-tests missing-origin, unknown/missing/changed retained-file discrimination.
|
||||
|
||||
## Status
|
||||
|
||||
Root execution completed on 2026-09-11 at12:41:29 UTC: direct Rust2024 compile with
|
||||
warnings denied (23.439s), self-check, inventory, and generate/check of baseline,
|
||||
OA rename and SA rename all passed. Total62.428s. The exact core tree is
|
||||
`a9c71e2c3fc9260d06f191c0d65b1e63e41edbec`; its compiled rlib SHA256 is
|
||||
`D282FEE7FF3B4FDD159EA85B12430262C8DA00E064BD290CCED380B6BA2335B5`.
|
||||
The oracle executable is
|
||||
`798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524`.
|
||||
Exact logs, execution metadata and all six pre-native fixture hashes are retained
|
||||
under [lifecycle-native-ytr83r-20260911](../../experiments/lifecycle-native-ytr83r-20260911/).
|
||||
|
||||
An independent read-only check confirmed64 elements/2 relations per Project,
|
||||
including12 scenarios/22 lifelines/24 occurrences. Both incomplete receiver and
|
||||
self-Delete cases were admitted. There are19 warnings and no errors:
|
||||
missing_system1, unallocated_leaf4, empty_sequence2, call_without_operation2,
|
||||
incomplete_reference2, missing_exchange6 and empty_port_contract2. These describe
|
||||
the intentionally partial engineering fixture; no validation was bypassed.
|
||||
|
||||
Each rename changes exactly one quoted name and adds12 UTF-8 bytes; every other
|
||||
source byte and identity is unchanged. All three rationale files remain identical
|
||||
86-byte Unicode text with exactly two CRLF lines and no bare LF. None of these
|
||||
predictions used native outputs. Pending: matching GUI, visual/interaction checks
|
||||
and every native Save/Undo/Redo/reopen step above.
|
||||
|
||||
## Separate Unicode display predictions — after reboot
|
||||
|
||||
`typography-wide` changes only the OA root receiver Lifeline name to eighteen
|
||||
`W` characters and the SA root receiver name to eighteen `界` characters.
|
||||
`typography-clusters` instead uses a combining accent and a joined emoji near
|
||||
the existing display cutoff. Both derive from the original baseline by replacing
|
||||
exactly the two named Lifeline declaration name tokens; reverse replacement
|
||||
reproduced every original byte. All six original fixture hashes remain unchanged.
|
||||
|
||||
The expected full strings, IDs, byte sizes and SHA256 values are preserved in
|
||||
`../../experiments/lifecycle-native-ytr83r-20260911/typography-predictions.json`.
|
||||
The unchanged, hash-verified original oracle admitted both inputs through its
|
||||
`same EXPECTED EXPECTED` path using ordinary core/source loading and validation.
|
||||
This proves input admission only; it is not a native comparison. Exact logs and
|
||||
timings are alongside the manifest in `typography-admission.json`.
|
||||
|
||||
Before opening either variant in Studio, copy it to a fresh native working
|
||||
directory. Compare every passive open/zoom/select/Save result with the immutable
|
||||
expected variant using `same EXPECTED ACTUAL`. Inspect Create label containment
|
||||
at 0.5x/1x/2x and minimum Fit, while retaining the original Create selection IDs.
|
||||
Select the neutral lane header/guide to inspect its full Lifeline name, then
|
||||
cancel property editing without changing it. Missing font glyphs are observations
|
||||
to record, not a reason to change the source names or weaken containment checks.
|
||||
No native output or pixel clipping acceptance is claimed yet.
|
||||
@@ -1,570 +0,0 @@
|
||||
//! Independent B4 native source fixture/oracle. No desktop renderer is linked.
|
||||
//! generate MODE DIR | check MODE ENTRY [PRE_UNDO_ENTRY] | same EXPECTED ACTUAL [PRE_UNDO_ENTRY]
|
||||
//! self-check | inventory; modes: baseline, oa-rename, sa-rename. All fixture identities are fixed.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
type Disk = BTreeMap<String, Vec<u8>>;
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(0x5ceab400000040008000000000000000 + n)
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Side {
|
||||
layer: Layer,
|
||||
word: &'static str,
|
||||
base: u128,
|
||||
}
|
||||
const SIDES: [Side; 2] = [
|
||||
Side {
|
||||
layer: Layer::Oa,
|
||||
word: "OA",
|
||||
base: 1000,
|
||||
},
|
||||
Side {
|
||||
layer: Layer::Sa,
|
||||
word: "SA",
|
||||
base: 2000,
|
||||
},
|
||||
];
|
||||
impl Side {
|
||||
fn id(self, n: u128) -> Id {
|
||||
id(self.base + n)
|
||||
}
|
||||
fn name(self, text: &str) -> String {
|
||||
format!("{} {text}", self.word)
|
||||
}
|
||||
}
|
||||
fn element(p: &mut Project, side: Side, n: u128, kind: Kind, name: &str, parent: Option<u128>) {
|
||||
let mut e = p
|
||||
.make_element(
|
||||
kind,
|
||||
side.layer,
|
||||
&side.name(name),
|
||||
parent.map(|n| side.id(n)),
|
||||
)
|
||||
.unwrap();
|
||||
e.id = side.id(n);
|
||||
e.alias = format!("life_{}_{}", side.word.to_lowercase(), n);
|
||||
p.elements.insert(e.id, e);
|
||||
}
|
||||
fn scenario(p: &mut Project, side: Side, n: u128, name: &str) {
|
||||
element(p, side, n, Kind::Scenario, name, None);
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
p.elements.get_mut(&side.id(n)).unwrap().shared_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*viewpoint = if side.layer == Layer::Oa {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
} else {
|
||||
ScenarioViewpoint::Functional
|
||||
};
|
||||
}
|
||||
fn line(p: &mut Project, side: Side, n: u128, owner: u128, work: Option<u128>, name: &str) {
|
||||
element(p, side, n, Kind::Lifeline, name, Some(owner));
|
||||
*p.elements
|
||||
.get_mut(&side.id(n))
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: work.map(|n| side.id(n)),
|
||||
};
|
||||
}
|
||||
fn occurrence(
|
||||
p: &mut Project,
|
||||
side: Side,
|
||||
n: u128,
|
||||
parent: u128,
|
||||
name: &str,
|
||||
body: OccurrenceBody,
|
||||
) {
|
||||
element(p, side, n, Kind::Occurrence, name, Some(parent));
|
||||
*p.elements
|
||||
.get_mut(&side.id(n))
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
fn steps(p: &mut Project, side: Side, owner: u128, ordered: &[u128]) {
|
||||
*p.elements
|
||||
.get_mut(&side.id(owner))
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = ordered.iter().map(|n| side.id(*n)).collect();
|
||||
}
|
||||
fn message(
|
||||
side: Side,
|
||||
sender: u128,
|
||||
receiver: u128,
|
||||
kind: MessageKind,
|
||||
resolved: bool,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: resolved.then(|| side.id(12)),
|
||||
sender: Some(side.id(sender)),
|
||||
receiver: Some(side.id(receiver)),
|
||||
message_kind: kind,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
fn fixture() -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.id = id(1);
|
||||
p.alias = "lifecycle_native".into();
|
||||
p.name = "Lifecycle native contract".into();
|
||||
p.description =
|
||||
"Fixed semantic inputs; lifecycle geometry is derived and never authored.".into();
|
||||
for side in SIDES {
|
||||
let work = if side.layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
element(&mut p, side, 10, work, "Controller work", None);
|
||||
element(&mut p, side, 11, work, "Receiver work", None);
|
||||
if side.layer == Layer::Oa {
|
||||
let relation = Relation {
|
||||
id: side.id(12),
|
||||
alias: "life_oa_service".into(),
|
||||
name: "OA Lifecycle service".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::Interaction, side.id(10), side.id(11)),
|
||||
};
|
||||
p.relations.insert(relation.id, relation);
|
||||
} else {
|
||||
// The port planner derives deterministic UUIDv5 endpoints from this fixed exchange ID.
|
||||
let commands = ports::plan_functional_exchange(
|
||||
&p,
|
||||
&ports::FunctionalExchangeRequest {
|
||||
id: side.id(12),
|
||||
alias: "life_sa_service".into(),
|
||||
name: "SA Lifecycle service".into(),
|
||||
description: String::new(),
|
||||
source: side.id(10),
|
||||
target: side.id(11),
|
||||
source_port: ports::PortChoice::Create,
|
||||
target_port: ports::PortChoice::Create,
|
||||
items: vec![],
|
||||
},
|
||||
)
|
||||
.expect("typed fixed-ID functional exchange must be admitted");
|
||||
p = execute_batch(&p, &commands).unwrap();
|
||||
}
|
||||
for (n, name) in [
|
||||
(100, "01 Root create action delete"),
|
||||
(200, "02 Preexisting empty"),
|
||||
(300, "03 Create only"),
|
||||
(400, "04 Conditional delete"),
|
||||
(500, "05 Unassigned receiver"),
|
||||
(600, "06 Self delete"),
|
||||
] {
|
||||
scenario(&mut p, side, n, name);
|
||||
line(&mut p, side, n + 1, n, Some(10), "Controller lane");
|
||||
if n != 600 {
|
||||
line(
|
||||
&mut p,
|
||||
side,
|
||||
n + 2,
|
||||
n,
|
||||
(n != 500).then_some(11),
|
||||
if n == 500 {
|
||||
"Unassigned receiver lane"
|
||||
} else {
|
||||
"Receiver lane"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
110,
|
||||
100,
|
||||
"Create receiver",
|
||||
message(side, 101, 102, MessageKind::Create, true),
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
111,
|
||||
100,
|
||||
"Receiver action",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(side.id(11)),
|
||||
lifeline: Some(side.id(102)),
|
||||
},
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
112,
|
||||
100,
|
||||
"Delete receiver",
|
||||
message(side, 101, 102, MessageKind::Delete, true),
|
||||
);
|
||||
steps(&mut p, side, 100, &[110, 111, 112]);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
310,
|
||||
300,
|
||||
"Create only receiver",
|
||||
message(side, 301, 302, MessageKind::Create, true),
|
||||
);
|
||||
steps(&mut p, side, 300, &[310]);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
410,
|
||||
400,
|
||||
"Open call",
|
||||
message(side, 401, 402, MessageKind::Call, true),
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
411,
|
||||
400,
|
||||
"ALT delete or keep",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![side.id(412), side.id(413)],
|
||||
},
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
412,
|
||||
411,
|
||||
"Left delete operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "stop_requested".into(),
|
||||
occurrences: vec![side.id(414)],
|
||||
},
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
413,
|
||||
411,
|
||||
"Right empty operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "else".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
414,
|
||||
412,
|
||||
"Conditional delete receiver",
|
||||
message(side, 401, 402, MessageKind::Delete, true),
|
||||
);
|
||||
steps(&mut p, side, 400, &[410, 411]);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
510,
|
||||
500,
|
||||
"Create unassigned receiver",
|
||||
message(side, 501, 502, MessageKind::Create, false),
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
511,
|
||||
500,
|
||||
"Delete unassigned receiver",
|
||||
message(side, 501, 502, MessageKind::Delete, false),
|
||||
);
|
||||
steps(&mut p, side, 500, &[510, 511]);
|
||||
occurrence(
|
||||
&mut p,
|
||||
side,
|
||||
610,
|
||||
600,
|
||||
"Self delete controller",
|
||||
message(side, 601, 601, MessageKind::Delete, false),
|
||||
);
|
||||
steps(&mut p, side, 600, &[610]);
|
||||
}
|
||||
execute_batch(&p, &[]).expect("complete source fixture must satisfy ordinary core admission; never bypass self/incomplete-lane rejection")
|
||||
}
|
||||
fn baseline() -> CompiledSourceProject {
|
||||
let p = fixture();
|
||||
let entry = language::format(&p).replacen("{\n", "{\n include \"rationale.arcadia\";\n", 1);
|
||||
let raw = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry),
|
||||
("rationale.arcadia".into(), "// Preserve lifecycle note: 雨 · receiver.\r\n// Geometry never changes these bytes.\r\n".into()),
|
||||
]) };
|
||||
let compiled = compile_bundle(&raw, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, p);
|
||||
assert_eq!(
|
||||
compiled.bundle, raw,
|
||||
"no identity insertion, normalization or migration"
|
||||
);
|
||||
compiled
|
||||
}
|
||||
fn expected(mode: &str) -> CompiledSourceProject {
|
||||
let base = baseline();
|
||||
let side = match mode {
|
||||
"baseline" => return base,
|
||||
"oa-rename" => SIDES[0],
|
||||
"sa-rename" => SIDES[1],
|
||||
_ => panic!("mode: baseline|oa-rename|sa-rename"),
|
||||
};
|
||||
let next = execute_batch(
|
||||
&base.decoded.project,
|
||||
&[Command::Rename(
|
||||
side.id(112),
|
||||
side.name("Delete receiver · reviewed"),
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
update_bundle(&base, &next, ProjectLimits::default()).unwrap()
|
||||
}
|
||||
fn disk(root: &Path, here: &Path, out: &mut Disk) {
|
||||
for entry in fs::read_dir(here).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let kind = entry.file_type().unwrap();
|
||||
assert!(!kind.is_symlink(), "checkpoint cannot contain symlinks");
|
||||
if kind.is_dir() {
|
||||
disk(root, &entry.path(), out);
|
||||
} else {
|
||||
assert!(kind.is_file(), "checkpoint files must be ordinary files");
|
||||
let path = entry.path();
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace('\\', "/");
|
||||
out.insert(name, fs::read(path).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Snapshot {
|
||||
compiled: CompiledSourceProject,
|
||||
disk: Disk,
|
||||
}
|
||||
fn load(entry: &Path) -> Snapshot {
|
||||
assert_eq!(entry.file_name().unwrap(), "project.arcadia");
|
||||
let raw = source_files::load_project(entry, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.reachable_bundle();
|
||||
let compiled = compile_bundle(&raw, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle, raw, "no implicit source normalization");
|
||||
execute_batch(&compiled.decoded.project, &[]).unwrap();
|
||||
let mut files = Disk::new();
|
||||
let root = entry.parent().unwrap();
|
||||
disk(root, root, &mut files);
|
||||
Snapshot {
|
||||
compiled,
|
||||
disk: files,
|
||||
}
|
||||
}
|
||||
fn source_disk(bundle: &SourceBundle) -> Disk {
|
||||
bundle
|
||||
.files
|
||||
.iter()
|
||||
.map(|(name, text)| (name.clone(), text.as_bytes().to_vec()))
|
||||
.collect()
|
||||
}
|
||||
fn expected_disk(bundle: &SourceBundle, origin: Option<&Disk>) -> Disk {
|
||||
let mut files = origin.cloned().unwrap_or_default();
|
||||
files.extend(source_disk(bundle));
|
||||
files
|
||||
}
|
||||
fn equal(expected: &CompiledSourceProject, actual: &Snapshot, origin: Option<&Disk>) {
|
||||
assert_eq!(
|
||||
actual.compiled.decoded.project, expected.decoded.project,
|
||||
"whole Project: every identity, field, order, reference and geometry"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.compiled.bundle, expected.bundle,
|
||||
"whole reachable SourceBundle including retained note bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.disk,
|
||||
expected_disk(&expected.bundle, origin),
|
||||
"whole physical file map: only exact pre-Undo retained files are permitted"
|
||||
);
|
||||
}
|
||||
fn self_check() {
|
||||
let base = baseline();
|
||||
assert_eq!(
|
||||
base.decoded.project,
|
||||
fixture(),
|
||||
"fixture deterministic, including typed ports"
|
||||
);
|
||||
assert_eq!(base.bundle, baseline().bundle);
|
||||
for mode in ["baseline", "oa-rename", "sa-rename"] {
|
||||
let prediction = expected(mode);
|
||||
let actual = Snapshot {
|
||||
disk: source_disk(&prediction.bundle),
|
||||
compiled: expected(mode),
|
||||
};
|
||||
equal(&prediction, &actual, None);
|
||||
if mode != "baseline" {
|
||||
assert_ne!(prediction.decoded.project, base.decoded.project);
|
||||
assert_ne!(prediction.bundle, base.bundle);
|
||||
let side = if mode == "oa-rename" {
|
||||
SIDES[0]
|
||||
} else {
|
||||
SIDES[1]
|
||||
};
|
||||
let mut independently_named = base.decoded.project.clone();
|
||||
independently_named
|
||||
.elements
|
||||
.get_mut(&side.id(112))
|
||||
.unwrap()
|
||||
.name = side.name("Delete receiver · reviewed");
|
||||
assert_eq!(
|
||||
prediction.decoded.project, independently_named,
|
||||
"rename changes precisely one name; alias and identity stay fixed"
|
||||
);
|
||||
}
|
||||
let mut changed_project = prediction.decoded.project.clone();
|
||||
changed_project.description.push_str("unrequested");
|
||||
assert_ne!(changed_project, prediction.decoded.project);
|
||||
let mut changed_source = prediction.bundle.clone();
|
||||
changed_source
|
||||
.files
|
||||
.get_mut("rationale.arcadia")
|
||||
.unwrap()
|
||||
.push_str("// unrequested\n");
|
||||
assert_ne!(changed_source, prediction.bundle);
|
||||
}
|
||||
let plain = source_disk(&base.bundle);
|
||||
let mut origin = plain.clone();
|
||||
origin.insert(
|
||||
"detached.arcadia".into(),
|
||||
b"// exact pre-Undo bytes\r\n".to_vec(),
|
||||
);
|
||||
let retained = expected_disk(&base.bundle, Some(&origin));
|
||||
assert_eq!(retained, origin);
|
||||
assert_ne!(
|
||||
retained,
|
||||
expected_disk(&base.bundle, None),
|
||||
"origin is mandatory to authorize retained files"
|
||||
);
|
||||
let mut missing = retained.clone();
|
||||
missing.remove("detached.arcadia");
|
||||
assert_ne!(missing, retained);
|
||||
let mut changed = retained.clone();
|
||||
changed.get_mut("detached.arcadia").unwrap().push(b'!');
|
||||
assert_ne!(changed, retained);
|
||||
let mut unknown = retained.clone();
|
||||
unknown.insert("unknown.arcadia".into(), vec![]);
|
||||
assert_ne!(unknown, retained);
|
||||
let mut wrong_reachable = origin.clone();
|
||||
wrong_reachable.insert("rationale.arcadia".into(), b"wrong prior contents".to_vec());
|
||||
assert_eq!(
|
||||
expected_disk(&base.bundle, Some(&wrong_reachable)),
|
||||
retained,
|
||||
"expected reachable bytes override the origin"
|
||||
);
|
||||
println!(
|
||||
"PASS self-check: fixed OA/SA fixture, exact name-only predictions, explicit retained-file origin policy"
|
||||
);
|
||||
}
|
||||
fn main() {
|
||||
assert_eq!(SCHEMA_VERSION, 7);
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("self-check") => {
|
||||
assert_eq!(args.len(), 2);
|
||||
self_check();
|
||||
}
|
||||
Some("inventory") => {
|
||||
assert_eq!(args.len(), 2);
|
||||
let base = baseline();
|
||||
for e in
|
||||
base.decoded.project.elements.values().filter(|e| {
|
||||
matches!(e.kind(), Kind::Scenario | Kind::Lifeline | Kind::Occurrence)
|
||||
})
|
||||
{
|
||||
println!(
|
||||
"{} | {:?} | {} | parent={:?}",
|
||||
e.id,
|
||||
e.kind(),
|
||||
e.name,
|
||||
e.parent
|
||||
);
|
||||
}
|
||||
for issue in validation::validate(&base.decoded.project) {
|
||||
println!("DIAGNOSTIC {issue:?}");
|
||||
}
|
||||
}
|
||||
Some("generate") => {
|
||||
assert_eq!(args.len(), 4, "generate MODE FRESH_DIR");
|
||||
let prediction = expected(&args[2]);
|
||||
let dir = Path::new(&args[3]);
|
||||
assert!(!dir.exists(), "preserve existing fixture/evidence");
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
for (name, text) in &prediction.bundle.files {
|
||||
let path = dir.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text.as_bytes()).unwrap();
|
||||
}
|
||||
equal(&prediction, &load(&dir.join("project.arcadia")), None);
|
||||
println!(
|
||||
"PASS generated {}: {} fixed elements, {} relations, {} exact sources",
|
||||
args[2],
|
||||
prediction.decoded.project.elements.len(),
|
||||
prediction.decoded.project.relations.len(),
|
||||
prediction.bundle.files.len()
|
||||
);
|
||||
}
|
||||
Some("check") => {
|
||||
assert!(
|
||||
(4..=5).contains(&args.len()),
|
||||
"check MODE ENTRY [PRE_UNDO_ENTRY]"
|
||||
);
|
||||
let actual = load(Path::new(&args[3]));
|
||||
let origin = args.get(4).map(|path| load(Path::new(path)));
|
||||
equal(
|
||||
&expected(&args[2]),
|
||||
&actual,
|
||||
origin.as_ref().map(|s| &s.disk),
|
||||
);
|
||||
println!(
|
||||
"PASS {}: complete Project/reachable sources/physical map",
|
||||
args[2]
|
||||
);
|
||||
}
|
||||
Some("same") => {
|
||||
assert!(
|
||||
(4..=5).contains(&args.len()),
|
||||
"same VERIFIED_EXPECTED_ENTRY ACTUAL_ENTRY [PRE_UNDO_ENTRY]"
|
||||
);
|
||||
let wanted = load(Path::new(&args[2]));
|
||||
let actual = load(Path::new(&args[3]));
|
||||
let origin = args.get(4).map(|path| load(Path::new(path)));
|
||||
assert_eq!(
|
||||
actual.compiled.decoded.project,
|
||||
wanted.compiled.decoded.project
|
||||
);
|
||||
assert_eq!(actual.compiled.bundle, wanted.compiled.bundle);
|
||||
let files = origin.as_ref().map_or_else(
|
||||
|| wanted.disk.clone(),
|
||||
|s| expected_disk(&wanted.compiled.bundle, Some(&s.disk)),
|
||||
);
|
||||
assert_eq!(
|
||||
actual.disk, files,
|
||||
"exact prior checkpoint or explicit retained-file origin"
|
||||
);
|
||||
println!("PASS same: all identities, fields, reachable source and physical bytes");
|
||||
}
|
||||
_ => panic!(
|
||||
"self-check | inventory | generate MODE DIR | check MODE ENTRY [PRE_UNDO_ENTRY] | same EXPECTED ACTUAL [PRE_UNDO_ENTRY]"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"core_sha256": "D282FEE7FF3B4FDD159EA85B12430262C8DA00E064BD290CCED380B6BA2335B5",
|
||||
"seconds": 62.4279746,
|
||||
"completed_utc": "2026-09-11T12:41:29.1561610Z",
|
||||
"results": [
|
||||
{
|
||||
"seconds": 23.4388482,
|
||||
"name": "compile",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"seconds": 8.1831391,
|
||||
"name": "self-check",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"seconds": 4.7308799,
|
||||
"name": "inventory",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"seconds": 5.0343211,
|
||||
"name": "generate-baseline",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "check-baseline",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"seconds": 3.5094392,
|
||||
"name": "generate-oa-rename",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "check-oa-rename",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"seconds": 3.6603173,
|
||||
"name": "generate-sa-rename",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "check-sa-rename",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"core_tree": "a9c71e2c3fc9260d06f191c0d65b1e63e41edbec",
|
||||
"core_full_test_status": "Separate all-targets gate; no pass inferred from this oracle",
|
||||
"source_sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4",
|
||||
"exe_sha256": "798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"created_utc": "2026-09-11T18:15:30.508Z",
|
||||
"files": [
|
||||
{
|
||||
"source": ".local/lifecycle-native-ytr83r-20260911/lifecycle-native-contract.exe",
|
||||
"target": "lifecycle.exe",
|
||||
"bytes": 20700160,
|
||||
"sha256": "798d7a1907b894c13549bad35511b21d6bc7f3d67d63674042b7df71184c7524"
|
||||
},
|
||||
{
|
||||
"source": ".local/staged-source-native-ytr83r-20260911/staged-source-authoring-contract.exe",
|
||||
"target": "staged.exe",
|
||||
"bytes": 20823552,
|
||||
"sha256": "b1dea048e319fb7b9a41620a17a0db8b0831e09816accfdaec7cfbda0133c211"
|
||||
},
|
||||
{
|
||||
"source": ".local/child-work-native/compile-01/contract.exe",
|
||||
"target": "child.exe",
|
||||
"bytes": 20577280,
|
||||
"sha256": "c772bf4a6477bb540a838b20e60cf59c60dda51b6d2fd2cb3afee57f151a9c96"
|
||||
},
|
||||
{
|
||||
"source": ".local/toolchain/llvm-mingw-20260908-ucrt-x86_64/bin/libunwind.dll",
|
||||
"target": "libunwind.dll",
|
||||
"bytes": 190464,
|
||||
"sha256": "aa282fa47bb2c4e210cea5e470cdbdca58568f0be0bc73e404d86ab9cd95ef6a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"seconds": 385.1131455,
|
||||
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\studio-build.log",
|
||||
"started_utc": "2026-09-11T20:14:23.6348444Z",
|
||||
"exit": 0,
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--bin",
|
||||
"arcadia-studio",
|
||||
"--offline",
|
||||
"--locked"
|
||||
],
|
||||
"name": "canvas-union-windows-20260911/studio-build",
|
||||
"completed_utc": "2026-09-11T20:20:48.7677661Z"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"seconds": 0.4776694,
|
||||
"mode": "typography-wide",
|
||||
"scope": "ordinary core/source admission of fixed expected input only; no native output",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"seconds": 0.257928,
|
||||
"mode": "typography-clusters",
|
||||
"scope": "ordinary core/source admission of fixed expected input only; no native output",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"completed_utc": "2026-09-11T13:50:08.2961401Z",
|
||||
"oracle_sha256": "798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524"
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"origin": "baseline/project.arcadia",
|
||||
"scope": "Exactly the two root receiver Lifeline names; all other bytes unchanged",
|
||||
"native_acceptance": false,
|
||||
"variants": {
|
||||
"typography-wide": {
|
||||
"changes": [
|
||||
{
|
||||
"id": "5ceab400-0000-4000-8000-00000000044e",
|
||||
"name": "WWWWWWWWWWWWWWWWWW",
|
||||
"before": "oa_lifeline life_oa_102 \"OA Receiver lane\" id \"5ceab400-0000-4000-8000-00000000044e\"",
|
||||
"after": "oa_lifeline life_oa_102 \"WWWWWWWWWWWWWWWWWW\" id \"5ceab400-0000-4000-8000-00000000044e\""
|
||||
},
|
||||
{
|
||||
"id": "5ceab400-0000-4000-8000-000000000836",
|
||||
"name": "界界界界界界界界界界界界界界界界界界",
|
||||
"before": "sa_lifeline life_sa_102 \"SA Receiver lane\" id \"5ceab400-0000-4000-8000-000000000836\"",
|
||||
"after": "sa_lifeline life_sa_102 \"界界界界界界界界界界界界界界界界界界\" id \"5ceab400-0000-4000-8000-000000000836\""
|
||||
}
|
||||
],
|
||||
"files": {
|
||||
"project.arcadia": {
|
||||
"bytes": 20128,
|
||||
"sha256": "6a18ee1f246321bdf8ab0e7e8e5d4eba134ce486ed5224c4ffd61c7342c3a7d3"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 86,
|
||||
"sha256": "8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography-clusters": {
|
||||
"changes": [
|
||||
{
|
||||
"id": "5ceab400-0000-4000-8000-00000000044e",
|
||||
"name": "abcdefghijklmnopé尾",
|
||||
"before": "oa_lifeline life_oa_102 \"OA Receiver lane\" id \"5ceab400-0000-4000-8000-00000000044e\"",
|
||||
"after": "oa_lifeline life_oa_102 \"abcdefghijklmnopé尾\" id \"5ceab400-0000-4000-8000-00000000044e\""
|
||||
},
|
||||
{
|
||||
"id": "5ceab400-0000-4000-8000-000000000836",
|
||||
"name": "abcdefghijklmnop👩🔬 tail",
|
||||
"before": "sa_lifeline life_sa_102 \"SA Receiver lane\" id \"5ceab400-0000-4000-8000-000000000836\"",
|
||||
"after": "sa_lifeline life_sa_102 \"abcdefghijklmnop👩🔬 tail\" id \"5ceab400-0000-4000-8000-000000000836\""
|
||||
}
|
||||
],
|
||||
"files": {
|
||||
"project.arcadia": {
|
||||
"bytes": 20110,
|
||||
"sha256": "eecddfc4a4514e995633ea58b89fe154dc6dc43509b1f53ee05b76f8e6b993c8"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 86,
|
||||
"sha256": "8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
{
|
||||
"checked_utc": "2026-09-12T11:08:56.3639375Z",
|
||||
"scope": "Fresh original/copy reads and exact archive inventory; no runtime execution",
|
||||
"pass": true,
|
||||
"payload_count": 47,
|
||||
"payload_bytes": 2184242.0,
|
||||
"manifest_sha256": "F0FF9E5C21F88DDCBF59ED3F6A0F03A5B21EDFB3A0E447F8F0094615ABC3A0E8",
|
||||
"payloads": [
|
||||
{
|
||||
"path": "baseline-wide/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "baseline-wide/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "checkpoints/wide-passive/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "checkpoints/wide-passive/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "launch-script-syntax.json",
|
||||
"bytes": 77,
|
||||
"sha256": "9FE1369737B8A02EE9DDB83318CE800F200AFABF5486C9DD6F52DBEC9F87502C",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "launch.json",
|
||||
"bytes": 3308,
|
||||
"sha256": "7BCF09DFB689DB4E686BBACD0F1805B013597FB4159D5477D5DE56F52D79D147",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "logs/studio.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "logs/studio.stdout.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "logs/wide-passive.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "logs/wide-passive.stdout.log",
|
||||
"bytes": 71,
|
||||
"sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "normal-close.json",
|
||||
"bytes": 1326,
|
||||
"sha256": "21CE20643ED69CA1247B261A44A71A562850D0BCF8339761E60F11B7A19A7AF1",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "preparation-manifest.json",
|
||||
"bytes": 9149,
|
||||
"sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "prepare-inputs.py",
|
||||
"bytes": 5563,
|
||||
"sha256": "661D6686AD0B59FBD28CB4FE42CFD4955DB5922B9CE3B5EF3F378A4CF1982884",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/after-build-verify-head.json",
|
||||
"bytes": 199,
|
||||
"sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/binary-manifest.json",
|
||||
"bytes": 2828,
|
||||
"sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/desktop-all-targets-status.json",
|
||||
"bytes": 472,
|
||||
"sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/desktop-clippy-status.json",
|
||||
"bytes": 503,
|
||||
"sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/focused-lifecycle-status.json",
|
||||
"bytes": 509,
|
||||
"sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/inputs.json",
|
||||
"bytes": 101859,
|
||||
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-fixture-README.md",
|
||||
"bytes": 12199,
|
||||
"sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-native-contract.rs",
|
||||
"bytes": 19135,
|
||||
"sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-oracle-status.json",
|
||||
"bytes": 1272,
|
||||
"sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/oracles-865-copy-manifest.json",
|
||||
"bytes": 1015,
|
||||
"sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/studio-build-status.json",
|
||||
"bytes": 474,
|
||||
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/typography-admission.json",
|
||||
"bytes": 553,
|
||||
"sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "provenance/typography-predictions.json",
|
||||
"bytes": 2490,
|
||||
"sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/01-wide-opened.jpg",
|
||||
"bytes": 102087,
|
||||
"sha256": "76B5ABF72AAA2B40DB91FCF182E3B464BEE29CC9ABDCCC4005EC9567554C7E18",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/02-behavior-open.jpg",
|
||||
"bytes": 118609,
|
||||
"sha256": "92D75BCF44D78221D99C393C039E6ED395AF70973477643EEF437CD0B5EFF1A2",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/03-oa-create-selected-100.jpg",
|
||||
"bytes": 134137,
|
||||
"sha256": "0028B918C56C5686890C18D730D3394CC07A8D0714DBD5175B06A336C2BD937C",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/04-oa-create-zoom125.jpg",
|
||||
"bytes": 131774,
|
||||
"sha256": "9B6627543E8A79E8D217029861407A89C884F36A3024BE44F5B8205AD2B557ED",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/05-oa-delete-selected-125.jpg",
|
||||
"bytes": 132774,
|
||||
"sha256": "1BC820418F12B18EBC97F52D2B2B85B8B9BDD57F9485BE040946FC9CC2F37FAA",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/06-oa-fit.jpg",
|
||||
"bytes": 133100,
|
||||
"sha256": "585063787D8E0299A7C2173BB8EC0264CDF51D5EDD8CBC3FCC953EF1917E1116",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/07-minimum-width.jpg",
|
||||
"bytes": 123533,
|
||||
"sha256": "9D798D0B087C7F05FAFDC23237BD934B069B69E1A08D8C33EAF74D4E85264582",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/08-minimum-window.jpg",
|
||||
"bytes": 105379,
|
||||
"sha256": "DFBAFFA375445BCD36565BDC8873DBF457E0A528A827E882F069B95D90529944",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/09-oa-minimum-fit.jpg",
|
||||
"bytes": 99033,
|
||||
"sha256": "FD2FEE1D602488243876B6FAEAFCBBCFE7065DDCFA2C28AD743A0FD19673C4B9",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/10-oa-minimum-create-hit.jpg",
|
||||
"bytes": 99898,
|
||||
"sha256": "7975C21C9E41D32A8684E3420DEF616D7AC1E43F5EF291E94380FA63DA37241F",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/11-oa-minimum-pan-right.jpg",
|
||||
"bytes": 99456,
|
||||
"sha256": "61BD5C917B738E1C3B2267D623A41193A8C5F84F5A1F7B51DAFA1B147DB13A51",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/12-oa-panned-delete-hit.jpg",
|
||||
"bytes": 98952,
|
||||
"sha256": "1B9C0E408BC9445AE0E6BDC2F86EA341AA385EB26D42693C76309963D9D4064C",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/13-system-layer.jpg",
|
||||
"bytes": 101904,
|
||||
"sha256": "02992B658775E55F0CBAF974050BBFEA0C1F608799C5D9BE9A05AA6789C9F1F4",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/14-sa-minimum-fit.jpg",
|
||||
"bytes": 95469,
|
||||
"sha256": "E4A274CA8DA7ECD2255EF519C8FC81D8310DB55C9DB9009FC8731751A25202CB",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/15-sa-minimum-create-hit.jpg",
|
||||
"bytes": 99837,
|
||||
"sha256": "4ED02839A417EE567CD8F23750DFFB535AF8C32063B535F8C0B4BE8ACB082542",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/16-sa-pan-up.jpg",
|
||||
"bytes": 96836,
|
||||
"sha256": "26048071F3B2A09ED054D18834C128093CC1FB9B1C4A7518B09E0F9E9EC8291A",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/17-sa-panned-delete-hit.jpg",
|
||||
"bytes": 96499,
|
||||
"sha256": "31EA2A24959B4A5014C9FCD0E43BD918847C6FB58EB0B8B1577C56074E266BC0",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "screenshots/18-passive-navigation-saved.jpg",
|
||||
"bytes": 94345,
|
||||
"sha256": "280942F9B9806884D4EE34A5FB740428A420D7603EDDDF21D6E4979BEE041B2D",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "second-readback.json",
|
||||
"bytes": 6332,
|
||||
"sha256": "BB8063AAC3FFF3A7B8FC19BA81F9EBBC13068871F6924651A14B08D58CE525BB",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "wide-passive-status.json",
|
||||
"bytes": 857,
|
||||
"sha256": "A358FA3C26A11DEACFC9457EC96F27F43EBD9020575329E701F0F026759850BF",
|
||||
"original_equal": true
|
||||
},
|
||||
{
|
||||
"path": "launch-native-a57c334-camera.ps1",
|
||||
"bytes": 10001,
|
||||
"sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE",
|
||||
"original_equal": true
|
||||
}
|
||||
],
|
||||
"physical_same": {
|
||||
"pass": true,
|
||||
"bytes": 20214,
|
||||
"files": [
|
||||
"project.arcadia",
|
||||
"rationale.arcadia"
|
||||
]
|
||||
},
|
||||
"inventory_excludes": "readback.json self-reference only",
|
||||
"inventory": [
|
||||
{
|
||||
"path": "archive.ps1",
|
||||
"bytes": 2510,
|
||||
"sha256": "D60E5E478361DDB7428454F30BB6DD856403A41AB1816EA7264B09988099F1AB"
|
||||
},
|
||||
{
|
||||
"path": "baseline-wide/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"path": "baseline-wide/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"path": "checkpoints/wide-passive/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"path": "checkpoints/wide-passive/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"path": "launch-native-a57c334-camera.ps1",
|
||||
"bytes": 10001,
|
||||
"sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE"
|
||||
},
|
||||
{
|
||||
"path": "launch-script-syntax.json",
|
||||
"bytes": 77,
|
||||
"sha256": "9FE1369737B8A02EE9DDB83318CE800F200AFABF5486C9DD6F52DBEC9F87502C"
|
||||
},
|
||||
{
|
||||
"path": "launch.json",
|
||||
"bytes": 3308,
|
||||
"sha256": "7BCF09DFB689DB4E686BBACD0F1805B013597FB4159D5477D5DE56F52D79D147"
|
||||
},
|
||||
{
|
||||
"path": "logs/studio.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
{
|
||||
"path": "logs/studio.stdout.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
{
|
||||
"path": "logs/wide-passive.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
{
|
||||
"path": "logs/wide-passive.stdout.log",
|
||||
"bytes": 71,
|
||||
"sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
|
||||
},
|
||||
{
|
||||
"path": "manifest.json",
|
||||
"bytes": 19562,
|
||||
"sha256": "F0FF9E5C21F88DDCBF59ED3F6A0F03A5B21EDFB3A0E447F8F0094615ABC3A0E8"
|
||||
},
|
||||
{
|
||||
"path": "normal-close.json",
|
||||
"bytes": 1326,
|
||||
"sha256": "21CE20643ED69CA1247B261A44A71A562850D0BCF8339761E60F11B7A19A7AF1"
|
||||
},
|
||||
{
|
||||
"path": "preparation-manifest.json",
|
||||
"bytes": 9149,
|
||||
"sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB"
|
||||
},
|
||||
{
|
||||
"path": "prepare-inputs.py",
|
||||
"bytes": 5563,
|
||||
"sha256": "661D6686AD0B59FBD28CB4FE42CFD4955DB5922B9CE3B5EF3F378A4CF1982884"
|
||||
},
|
||||
{
|
||||
"path": "provenance/after-build-verify-head.json",
|
||||
"bytes": 199,
|
||||
"sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08"
|
||||
},
|
||||
{
|
||||
"path": "provenance/binary-manifest.json",
|
||||
"bytes": 2828,
|
||||
"sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA"
|
||||
},
|
||||
{
|
||||
"path": "provenance/desktop-all-targets-status.json",
|
||||
"bytes": 472,
|
||||
"sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0"
|
||||
},
|
||||
{
|
||||
"path": "provenance/desktop-clippy-status.json",
|
||||
"bytes": 503,
|
||||
"sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C"
|
||||
},
|
||||
{
|
||||
"path": "provenance/focused-lifecycle-status.json",
|
||||
"bytes": 509,
|
||||
"sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3"
|
||||
},
|
||||
{
|
||||
"path": "provenance/inputs.json",
|
||||
"bytes": 101859,
|
||||
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-fixture-README.md",
|
||||
"bytes": 12199,
|
||||
"sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78"
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-native-contract.rs",
|
||||
"bytes": 19135,
|
||||
"sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4"
|
||||
},
|
||||
{
|
||||
"path": "provenance/lifecycle-oracle-status.json",
|
||||
"bytes": 1272,
|
||||
"sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C"
|
||||
},
|
||||
{
|
||||
"path": "provenance/oracles-865-copy-manifest.json",
|
||||
"bytes": 1015,
|
||||
"sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C"
|
||||
},
|
||||
{
|
||||
"path": "provenance/studio-build-status.json",
|
||||
"bytes": 474,
|
||||
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
|
||||
},
|
||||
{
|
||||
"path": "provenance/typography-admission.json",
|
||||
"bytes": 553,
|
||||
"sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8"
|
||||
},
|
||||
{
|
||||
"path": "provenance/typography-predictions.json",
|
||||
"bytes": 2490,
|
||||
"sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"bytes": 4050,
|
||||
"sha256": "3B1866876D39147B1575F01C192721B1BA5F5216D3F12E17DC23FE39DD92FCDA"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/01-wide-opened.jpg",
|
||||
"bytes": 102087,
|
||||
"sha256": "76B5ABF72AAA2B40DB91FCF182E3B464BEE29CC9ABDCCC4005EC9567554C7E18"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/02-behavior-open.jpg",
|
||||
"bytes": 118609,
|
||||
"sha256": "92D75BCF44D78221D99C393C039E6ED395AF70973477643EEF437CD0B5EFF1A2"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/03-oa-create-selected-100.jpg",
|
||||
"bytes": 134137,
|
||||
"sha256": "0028B918C56C5686890C18D730D3394CC07A8D0714DBD5175B06A336C2BD937C"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/04-oa-create-zoom125.jpg",
|
||||
"bytes": 131774,
|
||||
"sha256": "9B6627543E8A79E8D217029861407A89C884F36A3024BE44F5B8205AD2B557ED"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/05-oa-delete-selected-125.jpg",
|
||||
"bytes": 132774,
|
||||
"sha256": "1BC820418F12B18EBC97F52D2B2B85B8B9BDD57F9485BE040946FC9CC2F37FAA"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/06-oa-fit.jpg",
|
||||
"bytes": 133100,
|
||||
"sha256": "585063787D8E0299A7C2173BB8EC0264CDF51D5EDD8CBC3FCC953EF1917E1116"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/07-minimum-width.jpg",
|
||||
"bytes": 123533,
|
||||
"sha256": "9D798D0B087C7F05FAFDC23237BD934B069B69E1A08D8C33EAF74D4E85264582"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/08-minimum-window.jpg",
|
||||
"bytes": 105379,
|
||||
"sha256": "DFBAFFA375445BCD36565BDC8873DBF457E0A528A827E882F069B95D90529944"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/09-oa-minimum-fit.jpg",
|
||||
"bytes": 99033,
|
||||
"sha256": "FD2FEE1D602488243876B6FAEAFCBBCFE7065DDCFA2C28AD743A0FD19673C4B9"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/10-oa-minimum-create-hit.jpg",
|
||||
"bytes": 99898,
|
||||
"sha256": "7975C21C9E41D32A8684E3420DEF616D7AC1E43F5EF291E94380FA63DA37241F"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/11-oa-minimum-pan-right.jpg",
|
||||
"bytes": 99456,
|
||||
"sha256": "61BD5C917B738E1C3B2267D623A41193A8C5F84F5A1F7B51DAFA1B147DB13A51"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/12-oa-panned-delete-hit.jpg",
|
||||
"bytes": 98952,
|
||||
"sha256": "1B9C0E408BC9445AE0E6BDC2F86EA341AA385EB26D42693C76309963D9D4064C"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/13-system-layer.jpg",
|
||||
"bytes": 101904,
|
||||
"sha256": "02992B658775E55F0CBAF974050BBFEA0C1F608799C5D9BE9A05AA6789C9F1F4"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/14-sa-minimum-fit.jpg",
|
||||
"bytes": 95469,
|
||||
"sha256": "E4A274CA8DA7ECD2255EF519C8FC81D8310DB55C9DB9009FC8731751A25202CB"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/15-sa-minimum-create-hit.jpg",
|
||||
"bytes": 99837,
|
||||
"sha256": "4ED02839A417EE567CD8F23750DFFB535AF8C32063B535F8C0B4BE8ACB082542"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/16-sa-pan-up.jpg",
|
||||
"bytes": 96836,
|
||||
"sha256": "26048071F3B2A09ED054D18834C128093CC1FB9B1C4A7518B09E0F9E9EC8291A"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/17-sa-panned-delete-hit.jpg",
|
||||
"bytes": 96499,
|
||||
"sha256": "31EA2A24959B4A5014C9FCD0E43BD918847C6FB58EB0B8B1577C56074E266BC0"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/18-passive-navigation-saved.jpg",
|
||||
"bytes": 94345,
|
||||
"sha256": "280942F9B9806884D4EE34A5FB740428A420D7603EDDDF21D6E4979BEE041B2D"
|
||||
},
|
||||
{
|
||||
"path": "second-readback.json",
|
||||
"bytes": 6332,
|
||||
"sha256": "BB8063AAC3FFF3A7B8FC19BA81F9EBBC13068871F6924651A14B08D58CE525BB"
|
||||
},
|
||||
{
|
||||
"path": "verify-readback.ps1",
|
||||
"bytes": 2811,
|
||||
"sha256": "405069041AB5C7D75BB345B272C6D287A7D87F144CCFBB550BF7486B6BBBCA02"
|
||||
},
|
||||
{
|
||||
"path": "wide-passive-status.json",
|
||||
"bytes": 857,
|
||||
"sha256": "A358FA3C26A11DEACFC9457EC96F27F43EBD9020575329E701F0F026759850BF"
|
||||
}
|
||||
],
|
||||
"final_file_count": 52
|
||||
}
|
||||
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,183 +0,0 @@
|
||||
{
|
||||
"scope": "Fresh readback only; no launcher/oracle/model execution",
|
||||
"manifest_sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB",
|
||||
"launcher_sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE",
|
||||
"files": [
|
||||
{
|
||||
"target": "baseline-wide/rationale.arcadia",
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"bytes": 86,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "working-wide/rationale.arcadia",
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"bytes": 86,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"target": "baseline-wide/project.arcadia",
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"bytes": 20128,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "working-wide/project.arcadia",
|
||||
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"bytes": 20128,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"target": "baseline-clusters/project.arcadia",
|
||||
"sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"bytes": 20110,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "working-clusters/project.arcadia",
|
||||
"sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"bytes": 20110,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"target": "baseline-clusters/rationale.arcadia",
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"bytes": 86,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "working-clusters/rationale.arcadia",
|
||||
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"bytes": 86,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"target": "provenance/lifecycle-fixture-README.md",
|
||||
"sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78",
|
||||
"bytes": 12199,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "bin/arcadia-studio.exe",
|
||||
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40",
|
||||
"bytes": 113028608,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "bin/libunwind.dll",
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A",
|
||||
"bytes": 190464,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/inputs.json",
|
||||
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"bytes": 101859,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/binary-manifest.json",
|
||||
"sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA",
|
||||
"bytes": 2828,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/after-build-verify-head.json",
|
||||
"sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08",
|
||||
"bytes": 199,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/studio-build-status.json",
|
||||
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC",
|
||||
"bytes": 474,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/desktop-clippy-status.json",
|
||||
"sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C",
|
||||
"bytes": 503,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/desktop-all-targets-status.json",
|
||||
"sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0",
|
||||
"bytes": 472,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/focused-lifecycle-status.json",
|
||||
"sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3",
|
||||
"bytes": 509,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "oracles/lifecycle.exe",
|
||||
"sha256": "798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524",
|
||||
"bytes": 20700160,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "oracles/libunwind.dll",
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A",
|
||||
"bytes": 190464,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/lifecycle-native-contract.rs",
|
||||
"sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4",
|
||||
"bytes": 19135,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/oracles-865-copy-manifest.json",
|
||||
"sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C",
|
||||
"bytes": 1015,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/lifecycle-oracle-status.json",
|
||||
"sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C",
|
||||
"bytes": 1272,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/typography-admission.json",
|
||||
"sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8",
|
||||
"bytes": 553,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"target": "provenance/typography-predictions.json",
|
||||
"sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243",
|
||||
"bytes": 2490,
|
||||
"source_copy_source_equal": true,
|
||||
"readonly": true
|
||||
}
|
||||
],
|
||||
"fresh_empty_directories": true
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$manifest = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'manifest.json') | ConvertFrom-Json
|
||||
$payloads = @()
|
||||
foreach ($entry in $manifest.files) {
|
||||
$copy = Join-Path $PSScriptRoot $entry.path
|
||||
$originalHash = (Get-FileHash -LiteralPath $entry.original).Hash
|
||||
$copyHash = (Get-FileHash -LiteralPath $copy).Hash
|
||||
if ($originalHash -ne $entry.original_sha256 -or $copyHash -ne $entry.copy_sha256 -or (Get-Item -LiteralPath $copy).Length -ne $entry.bytes -or (Get-Item -LiteralPath $entry.original).Length -ne $entry.bytes) { throw "Payload mismatch $($entry.path)" }
|
||||
$payloads += [ordered]@{path=$entry.path;bytes=$entry.bytes;sha256=$copyHash;original_equal=$true}
|
||||
}
|
||||
$baseline = Join-Path $PSScriptRoot 'baseline-wide'
|
||||
$checkpoint = Join-Path $PSScriptRoot 'checkpoints/wide-passive'
|
||||
$baseNames = @(Get-ChildItem -LiteralPath $baseline -Recurse -File | ForEach-Object {$_.FullName.Substring($baseline.Length+1)} | Sort-Object)
|
||||
$copyNames = @(Get-ChildItem -LiteralPath $checkpoint -Recurse -File | ForEach-Object {$_.FullName.Substring($checkpoint.Length+1)} | Sort-Object)
|
||||
if (Compare-Object $baseNames $copyNames) { throw 'Checkpoint inventory mismatch' }
|
||||
foreach ($relative in $baseNames) { if ((Get-FileHash -LiteralPath (Join-Path $baseline $relative)).Hash -ne (Get-FileHash -LiteralPath (Join-Path $checkpoint $relative)).Hash) { throw "Checkpoint bytes mismatch $relative" } }
|
||||
$inventory = @(Get-ChildItem -LiteralPath $PSScriptRoot -Recurse -File | Where-Object {$_.FullName -ne (Join-Path $PSScriptRoot 'readback.json')} | Sort-Object FullName | ForEach-Object { [ordered]@{path=$_.FullName.Substring($PSScriptRoot.Length+1).Replace('\','/');bytes=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash} })
|
||||
$expected = @($manifest.files.path) + @('README.md','archive.ps1','verify-readback.ps1','manifest.json')
|
||||
if (Compare-Object ($expected|Sort-Object) ($inventory.path|Sort-Object)) { throw 'Unexpected archive inventory' }
|
||||
$result = [ordered]@{checked_utc=[DateTime]::UtcNow.ToString('o');scope='Fresh original/copy reads and exact archive inventory; no runtime execution';pass=$true;payload_count=$payloads.Count;payload_bytes=($payloads|ForEach-Object { $_.bytes }|Measure-Object -Sum).Sum;manifest_sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot 'manifest.json')).Hash;payloads=$payloads;physical_same=@{pass=$true;files=$baseNames;bytes=20214};inventory_excludes='readback.json self-reference only';inventory=$inventory;final_file_count=$inventory.Count+1}
|
||||
[IO.File]::WriteAllText((Join-Path $PSScriptRoot 'readback.json'),($result|ConvertTo-Json -Depth 7)+"`n",[Text.UTF8Encoding]::new($false))
|
||||
[pscustomobject]$result | Select-Object pass,payload_count,payload_bytes,manifest_sha256,final_file_count | ConvertTo-Json
|
||||