Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ef6868f00 | ||
|
|
819c360a6c | ||
|
|
1e848019d9 | ||
|
|
be00bc6f65 |
@@ -61,12 +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
|
||||
|
||||
@@ -35,20 +35,28 @@ Use Gitea for live coordination and the relevant code version for implementation
|
||||
|
||||
## Coordination and integration
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, branch/worktree, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and uncommitted or unpublished work; make required artifacts accessible to other sessions. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Use the highest available reasoning effort for orchestration and foundational semantic, methodological, formal, or cross-domain decisions. For subagents, use extra-high for difficult design, adversarial review, or unusually hard implementation; high for normal implementation, targeted research, correctness review, debugging, and runtime evaluation; medium for routine exploration, mechanical changes, builds, and tests; and lower for trivial work. Allocate by difficulty, not length, using supported controls; do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
|
||||
@@ -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,13 +1,9 @@
|
||||
# Integrated checkpoint — 2026-09-11
|
||||
|
||||
Codex-ytr83r, isolated branch `codex/canvas-union-ytr83r-20260911`. Current source `cb91525` adds the three-file grapheme shortening correction to the combined `a57c334b302eb13352b4bb65df1c75d94d002348` source checkpoint; its remaining Windows gates and matching native checks remain pending. The a57/108b checkpoint evidence remains preserved; this source statement is not a release claim. Main checkpoint `32334c6` / production `865483b` and its existing package remain preserved. The full [user goal](../PROJECT_GOAL.md) remains incomplete. 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) and the [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) retain ownership and plans. This file scopes code and evidence.
|
||||
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 `cb91525` changes the shared display helper to preserve extended grapheme clusters and records the direct locked dependency edge. Independent source review found no remaining actionable finding; four focused shortening tests passed at 20:59:36.0538079 UTC, with 752 filtered. Full desktop, strict Clippy, build and matching native checks remain pending in this record. The corrected 529-file input freeze supersedes only the premature pre-resolution freeze, against which no tests/build ran. [Correction, exact freezes and limits](release-grapheme-shortening-ytr83r-20260911.md), [verbatim independent review](release-grapheme-shortening-review-20260911.md).
|
||||
|
||||
The isolated `a57c334` union selectively combines peer `a31b40d` canvas/chooser/form prerequisites with the preserved B4 lifetime renderer, contextual source/minimum-card corrections and responsibility-preserving child-work creation. Pan-aware header/Create clips and pointer inversion received independent source review. Windows lifecycle **27**, full desktop **752**, strict desktop all-target Clippy and Studio build passed; all 529 frozen inputs matched HEAD and remained unchanged. The new unoptimized GUI is `69DBB90F…`; matching a57 native checks are in progress locally; complete acceptance remains pending. [Canvas union checkpoint, source/gate archives and exact limits](release-canvas-union-ytr83r-20260911.md).
|
||||
|
||||
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.
|
||||
@@ -19,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
|
||||
|
||||
@@ -38,11 +34,9 @@ The extracted package's unchanged server `ABA87A5F…` passed the actual HTTP sc
|
||||
|
||||
## 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. Full Unicode presentation and matching a57 native acceptance remain open; see the [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; they are not promoted to successful-open observations. Contextual-source workflows, matching new-canvas pan/zoom/clipping/focus behavior and complete combined native/package acceptance remain unfinished. 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 [closed Home/Guide/normal-close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves these bounded old865/BF9 observations and complete package readback. No application exit code was captured; no exit0 or new-source native pass is inferred.
|
||||
|
||||
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,20 +0,0 @@
|
||||
# Canvas union a57c334 source-review archive
|
||||
|
||||
This immutable source-review snapshot supports combined source commit `a57c334b302eb13352b4bb65df1c75d94d002348`, selectively integrating 42 source files in the isolated canvas worktree. Baseline: `32334c66317692befb6517a917dc7ae90641c11a` with production `865483b4c66361a01fe0d9e731859220bafb4136`. Peer: `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`.
|
||||
|
||||
The seven payloads preserve six private source/review reports plus the frozen build-input manifest. `manifest.json` maps every original main-checkout private path to its archive path, byte size, original SHA256 and copied SHA256. Original report text and private references remain unchanged; use that mapping to resolve archived references without assuming private files exist on another host. `readback.json` independently rereads originals and copies and records the second comparison.
|
||||
|
||||
Root reported verify-head PASS at 2026-09-11 19:44:32 UTC: all 529 raw input files, 9,712,849 bytes, matched a57c334 Git bytes. The archived `inputs.json` SHA256 is `47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3`. Its original capture at 19:42:03 UTC describes an uncommitted candidate; that historical wording is preserved. The later root equality result establishes the commit mapping. This archiver verified the manifest payload and its 529-entry/byte totals; it did not independently rerun the source-to-Git equality check.
|
||||
|
||||
Review order and scope:
|
||||
|
||||
- `next-integration-865483b-review.md`: historical preflight, exact peer/source boundaries and risks. Later work supersedes its pending integration status.
|
||||
- `canvas-prerequisite-union-report.md`: implemented App/chooser/form/session/workflow union, preserving child-work and corrected source cards.
|
||||
- `canvas-pan-independent-review.md`: independent pre-implementation transform and clipping obligations; not a final-candidate verdict.
|
||||
- `canvas-renderer-union-report.md`: B4/pan implementation and preservation checks; its pending-review wording predates the final reviews.
|
||||
- `canvas-root-prerequisite-final-review.md`: no actionable finding in the reviewed App/root prerequisite union; exact source hashes.
|
||||
- `canvas-renderer-final-review.md`: no material finding in three frozen renderer files, preserving the distinction between arithmetic/Program tests and native pixel evidence.
|
||||
|
||||
Peer a31's 729 tests and strict Clippy were already closed; they validate a31, not this union. Root separately reports newer allocation-planner 29 tests passed, with its UI still unaccepted; that separate contribution is not accepted by this archive. Other historical preflight readiness statements remain scoped to their observation time.
|
||||
|
||||
This is source evidence only. At handoff, focused Windows compilation was running and no combined test result existed. Root will archive closed gates and add the source-version record separately. No build, test, compiler, GUI, WSL, Git/index or Gitea operation was performed to create this archive. Raw-input ZIPs, temporary source reconstructions, live logs, binaries, package payloads and unrelated private work are excluded.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Independent camera/B4 union obligations
|
||||
|
||||
Codex-ytr83r /root/windows_release_gap_review, 2026-09-11. Read-only review of frozen root32334c6 and fetched peer a31b40d; no review or edits of the worker's changing union files. No compiler, tests, GUI, WSL/Linux, Git mutation or product/library research. Actual union-diff review remains separate after worker freeze.
|
||||
|
||||
## Primary evidence
|
||||
|
||||
- Root32334c6 `crates/oa-desktop/src/behavior.rs`: `box_text_clip`, `Scene::draw`, `Scene::hit`. Header drawing clips the padded card in frame coordinates and reapplies only scale to its child frame. Root `behavior/scenario_lifecycle/render.rs`: `birth_label_clip`, `Rendering::draw_markers` does the same for Create text; marker identity/lifetime geometry and selection priority are already present.
|
||||
- Peer a31b40d `behavior.rs`: parent frame translates `camera_pan` before scale; cursor inversion is `(position_in(bounds)-camera_pan)/scale`. Peer `behavior/canvas_workspace.rs::change` rejects nonfinite pan deltas/results and invalid zoom inputs; `canvas_workspace/viewport.rs` owns gesture cancellation/focus and renderer layering. Camera is presentation state, with no model commands in this shared module.
|
||||
- Actual locked dependencies are registry `iced_graphics0.14.0`, `iced_wgpu0.14.0`, `iced_tiny_skia0.14.0`, `iced_widget0.14.2`, `iced_core0.14.0`, in main `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. They are not a located Git fork checkout.
|
||||
- `iced_graphics/src/geometry/frame.rs::with_clip` calls draft then paste. WGPU `src/geometry.rs:116` creates an identity transform; `draft:412` calls that constructor; `paste:416` appends child primitives without another transform or parent-clip intersection. `fill_text:306` records transformed text position/font size and the child's own clip bounds. Tiny-skia draft/paste follows the same identity/noninheritance rule.
|
||||
- `iced_widget/src/canvas.rs:300` adds the widget bounds-origin translation outside `Program::draw`. Thus program text/clip coordinates must remain canvas-local. `iced_core/src/rectangle.rs::intersection` computes float max/min then positive dimensions; these operations are not a finite-input validator.
|
||||
|
||||
## Five bounded test obligations
|
||||
|
||||
1. **Same coordinate map everywhere:** world point maps to `camera_pan + scale * point`. Parent and clipped child frames must translate pan, then scale, exactly once. Never add widget bounds origin, subtract the clip origin, or scale pan. For header rect(100,200,204,70), scale2, pan(-200,-400), viewport(300,100), expected padded clip is(20,16,280,84), and title anchor is(24,24). Scale-only clips incorrectly disappear; scale-before-pan text lands at(-176,-376). Equivalent Create capsule(100,200,140,28) gives clip(16,8,248,40) and text anchor(16,12). Keep original text and padding semantics.
|
||||
2. **Clipping is an intersection, not a relocation:** cover positive/negative pan, scales0.5/1/2 plus existing minimum scale0.05, partial visibility on every viewport edge, fully offscreen None, and nonzero widget origin. Use actual canvas viewport size, not world scene/content size or child clip size. Preserve the outer viewport/Scrollable renderer layer for ancestor visibility; do not turn a local clip into absolute screen coordinates. A negative/large finite pan may legitimately hide the entire scene; do not introduce a new camera clamp to make tests pass.
|
||||
3. **Hit inversion and existing priorities:** route a Create capsule center and neutral header through actual Program events with nonzero bounds origin, pan and nonunit scale. Selecting Create must still return its occurrence, neutral header its lifeline. Retain existing minimum-zoom regression that marker halos do not steal adjacent marker/header hits. Screen-space hit halos remain constant through world thresholds divided by scale. Include a pointer that is within the world shape but outside the visible widget; it must not activate it. Preserve ordered raw event positions and stale-gesture cancellation when camera/bounds/context changes; do not restore the old double Scrollable-offset adjustment.
|
||||
4. **Finite-domain robustness:** test NaN/infinite pan components, invalid scale, invalid viewport dimensions, and arithmetic overflow before rectangle intersection. With NaN clip x, f32 max/min can yield a plausible full-width viewport clip instead of None, so intersection alone is insufficient. Keep finite rectangle origins/sizes/transformed endpoints and strictly positive padded dimensions as helper preconditions. Peer camera event guards already prevent ordinary nonfinite event state, so this is a robustness obligation, not a demonstrated native defect. Do not broaden this into a new global camera architecture.
|
||||
5. **Presentation-only invariance and evidence limits:** camera pan/zoom/drawing must preserve complete Project, SourceBundle, IDs, lifetime intervals, activation rectangles, message endpoints and semantic history. Compare source/model before/after actual camera messages in the existing harness where available. Mathematical clip-helper tests alone cannot prove that draw() reapplies child translation/scale; inspect both call sites against Iced behavior, and reserve actual pixel containment for root's later native gate. No new rendering framework is needed for this union.
|
||||
|
||||
No material finding against an actual frozen union is claimed here. These are concrete integration falsifiers and review obligations. Root owns combined source, validation and native/release acceptance; the earlier native header fix and peer729-test evidence retain their original scope.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Canvas prerequisite/App union
|
||||
|
||||
Codex-ytr83r /root/wsl_startup_review, 2026-09-11. Implemented only in `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/canvas-union-ytr83r-20260911`, branch `codex/canvas-union-ytr83r-20260911`, root-provided baseline `32334c6`. Root owns the combined integration, review, formatting and all execution gates.
|
||||
|
||||
## Result and provenance
|
||||
|
||||
22 files changed by this worker, 844026 bytes at handoff. The 21 prerequisite files below exactly match published peer `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`, verified by original Git-blob bytes versus written SHA256. Existing files were checked unchanged against baseline before writing. Unchanged files within the leased trees were not rewritten.
|
||||
|
||||
App was merged by applying only `b2c3e54001fc19a05423bcd5808ceea78d13ff59 → a31b40d0...` App hunks to our source; it was not replaced by the peer App. The only baseline App difference from b2c was the responsibility-preserving child-work function. After integration, App differs from the peer by the same 52 added/12 removed lines; its entire `add_element` function matches baseline exactly (9,563 characters after LF normalization, SHA256 `8918DF74451FCCB586D4582F138D89B039674A58D6ADE02BA04DF6CF69D0A4C2`).
|
||||
|
||||
The prerequisite provenance is the reviewed published chain:
|
||||
|
||||
- `7f081ad4572a7f45d736377c9ebc4a039032c58a`: production reference chooser and scenario form focus/reveal.
|
||||
- `8895dd2e0f87c0f16085bf4b478b8c0fbed38685`: current parent-form focus and stale/mount guards.
|
||||
- `80f25882143d977ca4984691740c3a7ffd45aedd`: replacement-session reset and visible-scroll correction.
|
||||
- `3c9e6eeb64ce3efc03b29890b82199a42c21f335`: boxed semantic workflow events and constructor adaptations.
|
||||
- Final `a31b40d0...` supplies the completed camera App wiring, helper visibility and cleanup. No unrelated ancestor baseline was imported.
|
||||
|
||||
Root explicitly extended this worker's scope to `workflow.rs` and `app/review_navigation_tests.rs` for the boxed Event interface. Those changes are included. No other additional path was edited.
|
||||
|
||||
## Preserved behavior and interfaces
|
||||
|
||||
`behavior/creation.rs`, `behavior/source_draft.rs` and `tests.rs` remain exact baseline files, confirmed by scoped Git diff exit 0. Thus the staged-owner caption, minimum source-card layout and its tests, and all three child_work regression tests are retained. Peer regressions in the first two files were deliberately excluded. The root child-work function retains explicit allocation/role relation ID transfer, unambiguous later-child performer inheritance, and execute_batch candidate admission.
|
||||
|
||||
App now combines reference overlay, canvas focus root and scenario focus root; reference-message admission/reconciliation; camera lifetime/boundary guards and stamped toolbar dispatch; parent FormShown focus; camera-aware scenario handling; the final obsolete-action cleanup; and boxed semantic Event dereference. The prerequisite helpers supply current draft/session/generation reference identity, chooser adapters and actual mounted tests. Workflow uses `Semantic(Box<semantic_review::Event>)` and the `Event::semantic` constructor.
|
||||
|
||||
Root/other owners must provide the matching a31 interfaces in their leased files: `behavior::canvas_workspace::{Intent, Scope::input_owner, keyboard_enabled, focus_root, focus_task}`; topology/scenario camera events and camera boundary/zoom methods; scenario `FormShown`, `focus_stamp`, `take_form_focus`; and the behavior module declarations for form_session/canvas_workspace/runtime lifecycle. The root-owned process_canvas change must accompany this App. No missing additional prerequisite interface was identified within this worker's completed scope.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
Scoped `git diff --check` passed after all writes. Every copied prerequisite matched its pinned original SHA256, the protected files were unchanged, and the whole child-work function matched baseline. The remaining `Event::Semantic(...)` in semantic_review/batch.rs is a pattern-match arm; construction uses the boxed helper. This is source/readback verification only. No compiler, tests, Clippy, GUI, native, WSL, Git index/commit, main-checkout source or Gitea mutation was performed.
|
||||
|
||||
The peer's prior 729/Clippy result remains evidence for a31 only, not this B4/header/child-work union. Root must review and run the combined gates, including mounted chooser/parent-focus/camera checks, existing contextual/child-work regressions and pan-aware lifecycle/header clipping and hit tests. Navigation and private allocation drag are outside this change.
|
||||
|
||||
## Exact handoff inventory
|
||||
|
||||
Paths below are relative to `crates/oa-desktop/src/`. SHA256 values identify this pre-format handoff; later root edits or formatting require new fingerprints.
|
||||
|
||||
| Path | Bytes | SHA256 |
|
||||
| --- | ---: | --- |
|
||||
| `app.rs` | 220191 | `6EB8B4BCD8F73D4BFDAACF30985D8292547440D65EAF49F6BB82AC158FBC46D3` |
|
||||
| `app/reference_authoring.rs` | 11250 | `38E033E23E8135032F70117A376A5FE7BC68127C466559A35D7EC200932E3F77` |
|
||||
| `app/reference_authoring/tests.rs` | 20067 | `B96DDC6BFF7F8FDE97720E18A92070F891C1340348D2E19C7C9C5A7BDA650CE8` |
|
||||
| `app/reference_authoring/tests/mounted.rs` | 32805 | `14A4914A1C86E50C395672DAB8FE80D7AF679A79C9082890B509FAD7B7C543D9` |
|
||||
| `app/review_navigation_tests.rs` | 7049 | `872FA91479DAD5436E139FFCAECE270323E9F5DAEEB7A4D80C2C2534F48BC21B` |
|
||||
| `behavior/form_session.rs` | 3941 | `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61` |
|
||||
| `behavior/scenario/focus.rs` | 9728 | `73A9EA10AD7312086081B8688396A21AB4251C9FAECC2DF6D984B702F3963533` |
|
||||
| `forms.rs` | 105544 | `EA124F479FCD2D3978AC3CB8EB81FEC4A7AAE5E4F4414C4BDD54849E954DEDF9` |
|
||||
| `forms/data_fields.rs` | 49285 | `A540A0D451BFF56A96B1D28643B2E8C0649F3846350C25C5B154729C0538E77C` |
|
||||
| `forms/reference_chooser.rs` | 12130 | `B60AF363BE333AF048859E51C28E8C989CB5C95D696CAD16C8EEC15F78D57A12` |
|
||||
| `forms/reference_chooser/adapter.rs` | 10447 | `87AB4BC3BF27FD4B5A458791CF62C7BA8E44E9EBF846D8B851B8B6FFB454CD64` |
|
||||
| `forms/reference_chooser/tests.rs` | 27835 | `8E98F8A3FCB814D4A201CC85B3DB87A689FD3D2CE487F8A2FE312FC296F93377` |
|
||||
| `forms/reference_chooser/trigger.rs` | 13336 | `34D0ACECFE6BED193970AA473CC3313984971264B86D7B362A6B84C23D013C0A` |
|
||||
| `forms/reference_chooser/trigger/focus.rs` | 9923 | `F1E79F4B03B2F9FD1CDC335054D4AE177DC9E759B6EA3D334B2BCCA39B57191B` |
|
||||
| `forms/reference_chooser/widget.rs` | 33804 | `D66CC17AF7A48BB8598BE683F084BB08FA7AF7304F9636F0A1BEA94C5CF6C727` |
|
||||
| `semantic_review.rs` | 3432 | `8DD1E93783D63A663C7E56C28775A5C389CFD7A6B562013D6A95C891AFA21ED8` |
|
||||
| `semantic_review/batch.rs` | 34485 | `A462EB627AE19348072775CF8E60B17D62F6B682B74CC413906D493FBA1F0B3C` |
|
||||
| `semantic_review/batch_tests.rs` | 27546 | `B67C4D7DCABAC710CF3D30730A8DB323180A558A415E2D117C868CDFA4804619` |
|
||||
| `semantic_review/row.rs` | 38159 | `B08FDC503C6C4D2FF99ADAC7E18C9BAA861BF86155E00B55D1E492430980D0F2` |
|
||||
| `semantic_review/tests.rs` | 32480 | `077EC6501DFAAB0B05EA3987943B204F08352952296D313555051D998CCD232E` |
|
||||
| `semantic_review/view.rs` | 57626 | `7CC7DD213AB01DADAD5DEBAEE868C30A1099466602869A799EBDF9B680E8EAF0` |
|
||||
| `workflow.rs` | 82963 | `D9E7B11A86FD3EC79D91F0C9BC9CEBD5A2043CDAA7261622454FB7730AD8E060` |
|
||||
|
||||
Primary source: [a31b40d0 publication](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/a31b40d0f8551ed87c8cd66250b9afb4809b3a79). Root's authorized integration scope is [issue 2 comment 490](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-490). The earlier private preflight remains `.local/next-integration-865483b-review.md`; this note records actual implementation, not inherited acceptance.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Frozen canvas renderer union — independent source review
|
||||
|
||||
Reviewer: Codex-ytr83r /root/windows_release_gap_review, 2026-09-11. Verdict: **no material integration finding in the three reviewed frozen files**. This is a source-review verdict, not compiler, test or native acceptance. Root owns combined integration and validation.
|
||||
|
||||
Reviewed worktree: `.local/canvas-union-ytr83r-20260911`, root baseline `32334c66317692befb6517a917dc7ae90641c11a`, peer renderer/camera source `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`.
|
||||
|
||||
| Reviewed file | SHA256 |
|
||||
| --- | --- |
|
||||
| crates/oa-desktop/src/behavior.rs | 82DCAAED9EB17F15329F80BCC7442FFF94C17D426A50DEA68455339F60FFA246 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs | BF1C05EA1E709B3503D8BCF92F091446FD7A9B80B1A29C5A55919DF58F3B0271 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs | A95201F7D0DCC88C9C54863F0E95E09C1DB204668743ABBD32C495B577EA53EF |
|
||||
|
||||
## Findings and preservation
|
||||
|
||||
All three hashes matched the worker's freeze. The parent frame, header text child frame and Create-label child frame each apply screen-space translation before uniform scale. Both clip helpers compute `pan + scale * world` once and intersect the actual parent frame size. Neither adds the widget origin or subtracts the clip origin. The reviewed draw call sites therefore agree with the underlying Iced transform/clip behavior.
|
||||
|
||||
The numerical regression correctly distinguishes missing or misordered pan. For header rectangle(100,200,204,70), scale2, pan(-200,-400), viewport(300,100), the padded clip is(20,16,280,84). For Create rectangle(100,200,140,28), the corresponding clip is(16,8,248,40). Other asserted positive/negative pan, partial-edge, fully offscreen and nonfinite-pan cases were checked against their arithmetic.
|
||||
|
||||
Independent readback from the frozen Git blobs and candidate files, explicitly decoded as UTF-8 and normalizing line endings, confirmed:
|
||||
|
||||
- The entire `impl Scene` geometry/hit section matches root32334c6 except initialization of `scroll_offset` becoming `camera_pan`.
|
||||
- The lifecycle rendering prefix before `draw_markers` and suffix from `marker_distance` remain unchanged.
|
||||
- All 15 existing lifecycle render test names remain, including minimum-scale marker/header and ordinary-message priority regressions. Two tests were added: `panned_header_and_create_clips_use_screen_translation_and_actual_viewport` and `real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity`.
|
||||
|
||||
The new Program test exercises OA/SA header, Create and Delete selection through mouse events at scales0.5/1/2, positive/negative pan and nonzero widget origin. Its `canvas::Program::update`, `Action::into_inner`, message matching and fixture API usage showed no source-level mismatch. Source inspection also confirmed the intended single Select publication for the tested press/release paths. No test execution by this reviewer is claimed.
|
||||
|
||||
An initial comparison using Python's platform-default text decoding produced a comparison failure around Unicode punctuation. Repeating the readback with explicit UTF-8 for both sides resolved that reviewer-tool issue and established the preservation results above; it did not require or identify a production change.
|
||||
|
||||
## Direct Iced provenance
|
||||
|
||||
The actual locked dependencies are registry packages, not a located Git fork: `iced_graphics0.14.0`, `iced_wgpu0.14.0`, `iced_tiny_skia0.14.0`, `iced_widget0.14.2` and `iced_core0.14.0`. The inspected copies are under main `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f`, matching Cargo.lock.
|
||||
|
||||
- `iced_graphics/src/geometry/frame.rs::with_clip` drafts a child frame and then pastes it.
|
||||
- `iced_wgpu/src/geometry.rs:116` initializes the frame transform to identity. `draft:412` constructs a fresh frame with the supplied clip; `paste:416` appends its primitives without reapplying the parent transform or intersecting the parent's clip. `fill_text:306` records transformed text position/font size and the child's clip bounds.
|
||||
- Tiny-skia's `geometry.rs` uses the same fresh-identity draft and direct-paste behavior.
|
||||
- `iced_widget/src/canvas.rs:300` applies widget-origin translation outside `Program::draw`, establishing canvas-local coordinates for these clip and text operations.
|
||||
- `iced_core/src/rectangle.rs::intersection` uses floating-point min/max followed by positive-dimension checks; it is not itself a general finite-input validator.
|
||||
|
||||
## Limits and remaining root validation
|
||||
|
||||
The new Program test does not dispatch App camera messages: its separately held Document remains untouched. It supports Scene hit identity and lifecycle equality, not complete App camera/history integration. Existing App/workspace camera, source and Undo/Redo gates must remain in combined validation.
|
||||
|
||||
The clip helpers reject nonfinite pan and invalid scale. General invalid rectangle/viewport coordinates and transformed arithmetic overflow are not fully covered or newly guarded. Production Scene geometry and peer camera event validation supply the ordinary finite-domain assumptions; this review found no demonstrated reachable regression requiring a broader camera rewrite. The original obligations note documents additional robustness falsifiers and remains unchanged.
|
||||
|
||||
Arithmetic clip tests do not establish native glyph containment. The reviewed child-frame call sites match the inspected Iced implementation, but actual rendered pixels, ancestor clipping, combined compiler/tests/Clippy and runtime acceptance remain root-owned. Earlier root707 and peer729 results retain their original source/runtime scope and do not validate this union.
|
||||
|
||||
No candidate source edits, compiler/test/native execution, Git mutations or WSL/Linux actions were performed by this reviewer. This file persists the already completed review; no additional investigation was performed to write it.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Isolated canvas renderer union
|
||||
|
||||
Owner: Codex-ytr83r /root/linux_build_plan. Worktree `.local/canvas-union-ytr83r-20260911`; baseline32334c66317692befb6517a917dc7ae90641c11a, preserving production865483b renderer. Peer pinned a31b40d0f8551ed87c8cd66250b9afb4809b3a79; known merge base c6d296a7e22c8f126834a1e217b88551634dd5cf. Only the three owned worktree files below were edited; main source, Git/index, compiler, native UI and WSL were untouched.
|
||||
|
||||
## Implemented
|
||||
|
||||
- behavior.rs: imported peer's scenario_scroll observer adaptation (process argument, Page-only details scrolling) and canvas_workspace/form_session module declarations, retaining unconditional scenario_lifecycle. Merged peer camera_pan input/draw mapping, Lifeline palette admission, ordinary row drag/release selection and cross-operand hint. Retained root B4 scene geometry, lifetimes, markers, clipping, marker priority and model/source construction. The existing pointer-order regression now covers the replacement camera-pan backend.
|
||||
- Header text and Create label clipping use the same local screen transform: pan + scale × world. Both fresh clipped child frames explicitly translate(pan) THEN scale(scale), just like the parent. Clip rectangles intersect actual parent frame size; widget absolute origin is not added. Nonfinite pan is rejected by clip helpers.
|
||||
- scenario_lifecycle/render.rs changes are limited to the pan argument for draw_markers, child transform, and birth_label_clip transform/guard. No marker/lifetime/hit semantics changed.
|
||||
- All15 existing lifecycle render tests remain, including .05/.1 minimum-zoom marker priority. Added2 regressions: independent absolute clip expectations for positive/negative pan at .5/1/2, partial viewport edges/offscreen/nonfinite cases; actual canvas::Program mouse-event selection for OA/SA header, Create and Delete identities with nonzero pan, nonunit zoom and nonzero widget origin. Unicode/source/lifecycle values remain unchanged.
|
||||
|
||||
Independent reviewer counterexample is included: rect100,200,204,70; pan(-200,-400),scale2,viewport300×100 produces header clip20,16,280,84; Create140×28 clip16,8,248,40. A scale-before-pan ordering would fail these expectations. The tests do not claim pixel validation or invoke a physical renderer; direct source inspection verifies both child-frame call sites.
|
||||
|
||||
## Validation and handoff
|
||||
|
||||
Scoped rustfmt2024 with skip_children=true formatted only these3 files; subsequent --check exit0. Correct-worktree git diff --check exit0. No compiler/test/native execution. Read-only source comparison against32334c6 proved the entire impl Scene geometry/hit section equal apart from initialization field scroll_offset→camera_pan; the B4 render/lifetime/hit prefix before draw_markers is byte-equal after line-ending normalization. Existing lifecycle test names were all retained (15→17).
|
||||
|
||||
Root owns canvas_workspace/**, scenario.rs/workspace/**, topology/** and guide integration; prerequisite worker owns App/forms. Renderer interface is Scene.camera_pan (screen units), scenario_scroll::observe(context,area,process,restore,content), and Rendering::draw_markers(frame,selected,scale,pan). Root was notified about remaining scene.scroll_offset expressions in its workspace/tests.rs:1193/1198 at the time of inspection; those belong to root's interface adaptation.
|
||||
|
||||
windows_release_gap_review supplied exact Iced draft/paste/transform obligations and has been asked to inspect these final candidate hashes. At this report write, candidate critique is pending. Root must compile and run combined candidate tests/Clippy and actual pan/zoom/viewport native checks before acceptance. Peer729 and prior root707 results do not validate this union.
|
||||
|
||||
## Frozen worker hashes
|
||||
|
||||
| Worktree file | SHA256 |
|
||||
| --- | --- |
|
||||
| crates/oa-desktop/src/behavior.rs | 82DCAAED9EB17F15329F80BCC7442FFF94C17D426A50DEA68455339F60FFA246 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs | BF1C05EA1E709B3503D8BCF92F091446FD7A9B80B1A29C5A55919DF58F3B0271 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs | A95201F7D0DCC88C9C54863F0E95E09C1DB204668743ABBD32C495B577EA53EF |
|
||||
@@ -1,59 +0,0 @@
|
||||
# Final root/prerequisite integration source review
|
||||
|
||||
Codex-ytr83r /root/wsl_startup_review, 2026-09-11. Read-only review of the isolated `.local/canvas-union-ytr83r-20260911` worktree against baseline `32334c6` and frozen peer `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`.
|
||||
|
||||
**No actionable integration finding in the reviewed scope.** This does not establish compilation or native acceptance. Root owns formatting and combined gates; the separate pan/B4 renderer implementation is not re-certified by this App/root-prerequisite review.
|
||||
|
||||
## Source comparison
|
||||
|
||||
The 17 root-owned canvas paths match a31 except the intentional guide union. App's SHA256 remains `6EB8B4BCD8F73D4BFDAACF30985D8292547440D65EAF49F6BB82AC158FBC46D3`, the exact prerequisite handoff reviewed earlier. Its sole difference from a31 is the preserved child-work correction (52 additions, 12 removals). The earlier full-function equality against baseline therefore still applies: `add_element` retains responsibility relation IDs, leaf-to-child transfer, unambiguous later-child inheritance and execute_batch candidate validation.
|
||||
|
||||
The guide correctly retains B4 lifecycle/uncertainty text and its search terms. The empty-path wording now directs Work (W) and a canvas click; the permanent Fragment palette instructions agree with the new controls. Existing advanced trace guidance is retained. No competing guide behavior was overwritten.
|
||||
|
||||
Scoped baseline comparisons confirm `behavior/creation.rs`, `behavior/source_draft.rs` and `tests.rs` unchanged. The staged-owner caption, minimum-card layout/tests and three child_work regressions survive. The prerequisite source, its boxed semantic workflow adaptations and published provenance are recorded in `.local/canvas-prerequisite-union-report.md`; this review did not rewrite them.
|
||||
|
||||
## Former First event and mounted successors
|
||||
|
||||
No stale `CanvasEvent::First`, `Shelf::Tools` or `ScrollArea::Canvas` use remains in the inspected App/process/scenario/topology paths. The final event vocabulary, App dispatch and workspace controls agree.
|
||||
|
||||
The replacement is actual user input through the permanent palette, not a renamed synthetic First test. At `behavior/scenario/workspace/tests.rs:352`, `production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas` mounts the App at 1100×700, visits both OA and SA empty scenarios, clicks the actual L control then the canvas, and requires the correct owner's Lifeline draft. It opens the staged source child, preserves viewport bounds, uses Escape, and checks retained invocation/raw name plus unchanged snapshot/source/Undo/Redo. App's `BeginOccurrence` Lifeline branch calls `State::update(Event::Start)`; other occurrence tools retain slot/lifeline admission through `begin_occurrence`. Dirty/pending admission is checked before either branch. The palette keeps lifeline, message styles, action, observation, reuse and Fragment reachable.
|
||||
|
||||
The same-size owner/document successor is `scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport` at line 1781. It reuses the same Mounted instance across OpenUse from owner A to B, requires the presentation viewport to be absent before settling, then observes the actual viewport again at unchanged dimensions. It subsequently replaces App/document while keeping the same semantic owner UUID, proves the document identity changed, again requires remeasurement, and validates Fit and unchanged source/history. This exercises the workspace sensor key `(context.document, owner)`, rather than assuming a resize notification will occur.
|
||||
|
||||
The mounted Open/Back successor at line 1828 also retains the caller's camera pan/zoom, selected occurrence/tool/panel and actual Details offset across definition navigation and preselection changes. It checks the observer consumed the one-shot restoration; camera pan is not a synthetic scrollable offset. The five parent-focus regressions remain present, including saved/chained Tasks and changed owner/document/mount/modal conditions.
|
||||
|
||||
## Interface and behavioral seams inspected
|
||||
|
||||
App's camera dispatch and root focus/chooser wrappers correspond to the pinned scenario/topology/canvas helpers. Scenario supplies current `focus_stamp`, `take_form_focus`, `FormShown`, `camera_boundary` and `camera_zoom`; process dispatch supplies the equivalent current-context/lifetime and pending/draft/source guards. Module declarations for canvas_workspace, form_session and scenario focus are present in the combined source. No additional missing prerequisite interface was identified.
|
||||
|
||||
Owner/document/revision guards remain on passive viewport/Details events; strict current context and clean-edit guards remain on authoring. The separate camera epoch permits its own ordered batch but invalidates stale presentation work on external boundary changes. Existing process/contextual identity and qualified selection behavior are retained from the frozen peer. This was a bounded integration review, not a new audit of every inherited peer algorithm.
|
||||
|
||||
## Verification limits and exact reviewed files
|
||||
|
||||
Direct no-filter Git blob comparisons verified all 16 unchanged peer files, including new untracked canvas modules; ordinary Git diff reports those untracked additions as absent when comparing against the peer, so it was not used as their byte proof. The three protected baseline comparisons and scoped diff whitespace validation passed. A second SHA256 readback found all 21 reviewed files unchanged. No compiler, tests, GUI, WSL, Git mutation or source edit was run by this reviewer. Assertions described above are verified test source, not freshly executed results. The peer's 729/Clippy pass and our earlier 707 pass remain tied to their original inputs; root's new combined results must identify the current union. The source-card layout, parent focus, actual canvas tasks and pan/B4 clipping/hit behavior still require the combined gates.
|
||||
|
||||
All paths below are relative to `crates/oa-desktop/src/`; these SHA256 values define this review's exact scope. The larger renderer file was read only for module-interface presence and is deliberately excluded from the accepted hash set because its dedicated worker/root owns that union.
|
||||
|
||||
| Path | Bytes | SHA256 |
|
||||
| --- | ---: | --- |
|
||||
| `app.rs` | 220191 | `6EB8B4BCD8F73D4BFDAACF30985D8292547440D65EAF49F6BB82AC158FBC46D3` |
|
||||
| `app/process_canvas.rs` | 30804 | `5A913BFA1E9E08D3A8AF314D2A883D55D1F1EED30E288546946F77C9F61CEB9C` |
|
||||
| `behavior/canvas_workspace.rs` | 7758 | `9303ED4F74EE0317E4ABEAB134F7C31F50EAC5EB5D85940D771CD3AD595258FA` |
|
||||
| `behavior/canvas_workspace/control.rs` | 7804 | `2B8D97B8C0E3FF1B1A2C32187B1D0EB25EDF39BA582B5FB3303BC880F674F7BA` |
|
||||
| `behavior/canvas_workspace/focus.rs` | 8943 | `CC58CB3838F546150D22FE4DD83A3FFD52504C279FEBAEB8D0B4A6A3B8A73950` |
|
||||
| `behavior/canvas_workspace/tests.rs` | 27020 | `D84D022574A6F1D69F203D9383502227FB9FC23605C6FB48779CA9F09BEE546D` |
|
||||
| `behavior/canvas_workspace/viewport.rs` | 14436 | `AF1C7C1E802D1207EE6C7BD66B703811A250D7B56511512AF6C0945C776E68A7` |
|
||||
| `behavior/contextual_source_tests.rs` | 49518 | `044BD412DC45FFE440B1E251993946942DC3759DCB2D5DFE71BD8D6EF61CBCC7` |
|
||||
| `behavior/scenario.rs` | 152266 | `CE99D5902F16629BE84F3F10F45CF632EEF037D22401E54482057552D983D017` |
|
||||
| `behavior/scenario/workspace.rs` | 25995 | `29F3BC14AA19AD110E60BAAAEA644A53DAABC0CFA7C8CA4542E9D5A2C11CB9D0` |
|
||||
| `behavior/scenario/workspace/tests.rs` | 80083 | `57649638930E4B47DCBC3F9F6A2F991DA4393DBC1EF243BBC09E01D40932F5BA` |
|
||||
| `behavior/topology.rs` | 84212 | `26E24AD0F781A3304C46299B3BD906917E57FAC262E7F52FBA4ADC80A4358EC5` |
|
||||
| `behavior/topology/contextual.rs` | 28432 | `5D0AC5B3E258FC70987A00EDE1E603681D99746596F2FCC46AD2B3667D65203A` |
|
||||
| `behavior/topology/direct.rs` | 36121 | `FA9CEAE78174D09904A9EBF90B0792AE9A2D8D26E3BE56A3891D5DE5345E49D0` |
|
||||
| `behavior/topology/direct_tests.rs` | 51170 | `EDCCFE3CE8B224065A3540AA53C6340F13DB0CECD48ECC0A3904CD832A483964` |
|
||||
| `behavior/topology/layout_tests.rs` | 36842 | `9031B2B2BD7AF47ADA3F1811C71F3EE372B07A545FB59C319A99D625F2D29EB6` |
|
||||
| `behavior/topology/view.rs` | 23136 | `B2D502C11426BFFB8763515F2A56AA4C9F8E203BB36D65EB34F81C123FC406A6` |
|
||||
| `guide.rs` | 116479 | `57DC7FFAD9FE07392C95F4493BE1931C48E2FB417ABB0F3B64B8007006AAFC7A` |
|
||||
| `behavior/creation.rs` | 29301 | `438A245EAD397060767C790C51A8F21CD5021342627999DEB4695CF3BD22091A` |
|
||||
| `behavior/source_draft.rs` | 39485 | `85D543CC288E48B4C13001E4E386B5B54D9B1E349A5AC31E4E307572F2D8FB3C` |
|
||||
| `tests.rs` | 75189 | `686DE3E6896CB322E3BA642FE5EF04F570E2FA04EB1956E08E958F3378FA4F1C` |
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"schema": "arcadia.closed-source-review-archive.v1",
|
||||
"captured_utc": "2026-09-11T19:46:50.7865645Z",
|
||||
"commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
|
||||
"scope": "Seven immutable source-review payloads; no combined execution acceptance",
|
||||
"payload_count": 7,
|
||||
"payload_bytes": 147501.0,
|
||||
"files": [
|
||||
{
|
||||
"original": ".local/canvas-prerequisite-union-report.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-prerequisite-union-report.md",
|
||||
"destination": "canvas-prerequisite-union-report.md",
|
||||
"bytes": 7652,
|
||||
"original_last_write_utc": "2026-09-11T19:31:50.7208334Z",
|
||||
"original_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9",
|
||||
"copy_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-renderer-union-report.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-renderer-union-report.md",
|
||||
"destination": "canvas-renderer-union-report.md",
|
||||
"bytes": 4147,
|
||||
"original_last_write_utc": "2026-09-11T19:36:57.0468036Z",
|
||||
"original_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B",
|
||||
"copy_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-pan-independent-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-pan-independent-review.md",
|
||||
"destination": "canvas-pan-independent-review.md",
|
||||
"bytes": 5592,
|
||||
"original_last_write_utc": "2026-09-11T19:32:27.3870393Z",
|
||||
"original_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D",
|
||||
"copy_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-root-prerequisite-final-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-root-prerequisite-final-review.md",
|
||||
"destination": "canvas-root-prerequisite-final-review.md",
|
||||
"bytes": 8560,
|
||||
"original_last_write_utc": "2026-09-11T19:43:20.6734961Z",
|
||||
"original_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48",
|
||||
"copy_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-renderer-final-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-renderer-final-review.md",
|
||||
"destination": "canvas-renderer-final-review.md",
|
||||
"bytes": 5870,
|
||||
"original_last_write_utc": "2026-09-11T19:43:56.3470460Z",
|
||||
"original_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4",
|
||||
"copy_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4"
|
||||
},
|
||||
{
|
||||
"original": ".local/next-integration-865483b-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\next-integration-865483b-review.md",
|
||||
"destination": "next-integration-865483b-review.md",
|
||||
"bytes": 13821,
|
||||
"original_last_write_utc": "2026-09-11T19:13:59.2031630Z",
|
||||
"original_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642",
|
||||
"copy_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-union-windows-20260911/inputs.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\inputs.json",
|
||||
"destination": "inputs.json",
|
||||
"bytes": 101859,
|
||||
"original_last_write_utc": "2026-09-11T19:42:04.9149380Z",
|
||||
"original_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"copy_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
# Next integration preflight against 865483b
|
||||
|
||||
Codex-ytr83r /root/wsl_startup_review, 2026-09-11. Private read-only planning; production baseline is `865483b4c66361a01fe0d9e731859220bafb4136` on `codex/release1-ytr83r`. Root's current packaged native acceptance remains pending. No integration, build, compiler, GUI, WSL, Git mutation, or Gitea write occurred in this task.
|
||||
|
||||
**Recommendation:** retain the current preview checkpoint. Leave navigation pending its four observed native corrections; prepare canvas only as a dedicated chooser/parent-focus + camera + B4/header + child-work union. Neither peer branch is a safe whole-file replacement for our production source. Issue 3 allocation drag is a separate unpublished implementation, not part of the accepted canvas source.
|
||||
|
||||
## Published source and actual readiness
|
||||
|
||||
| Contribution | Exact published source | Evidence and remaining limit |
|
||||
| --- | --- | --- |
|
||||
| Canvas/palette, peer 956atf | [`a31b40d0f8551ed87c8cd66250b9afb4809b3a79`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/a31b40d0f8551ed87c8cd66250b9afb4809b3a79), branch `codex/release1-956atf` | [Issue 2 comment 475](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-475): final 729 passed, no failed/ignored/filtered; strict desktop all-target Clippy passed at 18:38:52 UTC, 518 inputs unchanged. This supersedes the older report's failed obsolete-variant Clippy result. No final matching native acceptance was published in the inspected evidence; our B4 renderer was outside that peer union. |
|
||||
| Definition/References/Back, peer 9e8r6t | [`36db9356cce3c7614ae37eec9fe17678ca210afe`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/36db9356cce3c7614ae37eec9fe17678ca210afe); evidence/build tip [`8e00d3ce4f71eb8d6d1256ab283819c88c0fe803`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/8e00d3ce4f71eb8d6d1256ab283819c88c0fe803), branch `codex/release1-9e8r6t` | Earlier full desktop 737 passed + 1 ignored predates final replay/Escape changes. Final source had focused 181 passed + 1 ignored and strict desktop Clippy, not a repeated full 737. Matching GUI SHA256 `DF7126B8FC14C90985C2A0C55A640990F990E487D95A33F194AA7238754CEABD`, 113,197,056 bytes. Native work then found four unresolved defects. |
|
||||
| Allocation drag, issue 3 | No implementation commit published in inspected issue/branch evidence | [Comment 472](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3#issuecomment-472): private planner review found no remainder; three additional tests were being added, unexecuted. Private App/diagram candidate is outside the canvas freeze. Contract is published at `88c17a448fcecc9cf9cfb108d0f4315f925d4851`; this does not establish compiled/native gesture acceptance. |
|
||||
|
||||
Navigation [comment 479](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6#issuecomment-479) established useful native F12/References/Back, dirty-draft choice, source Undo, Find focus suppression and minimum-window editing, but also editor vertical movement when diagnostics clear/refresh and an empty 300 px pane after Close then Back. Latest [comment 482, 19:03:17 UTC](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6#issuecomment-482) adds Find-only requiring two Escapes and Alt+Space → Escape → neutral header click inserting a delayed space at the old caret. These corrections were still unpublished. The latter defect appeared only after the settled sequence; immediate screenshots were misleading. Visible Save All followed by the complete Project/ID and four reachable/four physical-file oracle passed after edits were restored, which does not clear these interaction defects. Its initial 52-payload archive was reported, not independently replayed here.
|
||||
|
||||
Pinned source reports: [canvas gate](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/a31b40d0f8551ed87c8cd66250b9afb4809b3a79/harness/release-behavior-canvas-windows-gate-20260911.md), [navigation integrated evaluation](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/8e00d3ce4f71eb8d6d1256ab283819c88c0fe803/harness/release-text-navigation-integrated-20260911.md). Earlier component counts, focused runs and full runs overlap and must not be added. The 100-module/10,000-use performance result measures worker capture/query, not GUI latency or model validity. Initial core 445 + 1 ignored preceded the corrected Unicode fixtures; those had a separate 12-test validation.
|
||||
|
||||
## Exact source boundaries
|
||||
|
||||
Canvas's 20 changed crate paths below are the exact complete-tree delta `638d06c4e33a1f54203b234d0b49c3ecf551d27d → a31b40d0...`. Intermediate implementation/corrections are `6969c825`, `06ec9621`, `ff017548`, `82fd0acf`, `abffa4b3`, `88c17a44`, then `a31b40d0`. Older candidate branch tip `ca19ad27` is superseded.
|
||||
|
||||
- `crates/oa-desktop/src/app.rs`
|
||||
- `crates/oa-desktop/src/app/process_canvas.rs`
|
||||
- `crates/oa-desktop/src/app/reference_authoring.rs`
|
||||
- `crates/oa-desktop/src/behavior.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/control.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/focus.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs`
|
||||
- `crates/oa-desktop/src/behavior/contextual_source_tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario/workspace.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/contextual.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/direct.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/direct_tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/layout_tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/view.rs`
|
||||
- `crates/oa-desktop/src/guide.rs`
|
||||
|
||||
This delta is **not self-contained on 865483b**. Its base already contains reference chooser, form-session and parent-focus plumbing. Local readback confirms these four entrypoints absent: `app/reference_authoring.rs`, `behavior/form_session.rs`, `behavior/scenario/focus.rs`, `forms/reference_chooser.rs`. The prerequisite closure also spans chooser adapter/trigger/focus/widget/tests; reference-authoring tests/mounted tests; forms/data_fields; scenario workspace; semantic-review row/view/batch interfaces and tests; boxed workflow events; App review-navigation bindings. Canvas's reference overlay and keyboard ownership call those APIs directly. This is a concrete dependency family, not a claim that a minimal cherry-pick/apply closure has been validated.
|
||||
|
||||
Do not wholesale import the base: it also includes unrelated analysis_numeric/core Cargo/lock changes. More seriously, peer `behavior/creation.rs` and `behavior/source_draft.rs` retain old pre-b2c blobs (`6940c7b9...`, `7c5dced5...`), whereas our corrected b2c versions are `d30281a4...`, `a8a39041...`. Replacing them would lose staged-owner/minimum-source-card corrections. Merge any needed chooser/session changes into our corrected files.
|
||||
|
||||
Navigation has these 18 exact crate paths relative to contextual `b2c3e54001fc19a05423bcd5808ceea78d13ff59`:
|
||||
|
||||
- `crates/oa-core/src/language.rs`
|
||||
- `crates/oa-core/src/language_outline_tests.rs`
|
||||
- `crates/oa-desktop/src/app.rs`
|
||||
- `crates/oa-desktop/src/app/hosted.rs`
|
||||
- `crates/oa-desktop/src/app/source_symbols.rs`
|
||||
- `crates/oa-desktop/src/app/source_symbols/tests.rs`
|
||||
- `crates/oa-desktop/src/guide.rs`
|
||||
- `crates/oa-desktop/src/source_navigation.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/controller.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/interaction.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/interaction/tests.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/query.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/query_tests.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/symbols.rs`
|
||||
- `crates/oa-desktop/src/source_workspace.rs`
|
||||
- `crates/oa-desktop/src/tests.rs`
|
||||
- `crates/oa-desktop/src/views.rs`
|
||||
- `crates/oa-desktop/src/views/source_symbols.rs`
|
||||
|
||||
Also preserve its language guide source/generated HTML pair, `docs/language.rst` and `docs/language.html`. The intermediate contextual publication `fb24def2...` has the same crate tree as b2c (`b77bddcf03b1869add8da06d14f94892b69a6b21`). Navigation source and evidence tip share crate tree `2638806948b29322f251777e6bb3d8f3f178ea1d`. Root Cargo.toml/Cargo.lock remain unchanged there; this nevertheless changes core language implementation, not just desktop. No inherited evidence archive or unrelated live record needs wholesale copying.
|
||||
|
||||
## Concrete merge risks
|
||||
|
||||
| Our retained file/behavior | Required union |
|
||||
| --- | --- |
|
||||
| `app.rs` child-work correction | Both peer `add_element` bodies are identical to each other and still pre-fix: neither transfers leaf responsibility relations nor validates the placement candidate with execute_batch. Keep our allocation/role target transfer preserving relation IDs, unambiguous later-child inheritance and atomic candidate execution. Merge other App hunks around it. |
|
||||
| `tests.rs` | Preserve all three child_work regressions. Navigation edits older source-find/reveal tests; canvas's 20-path delta does not touch this file. Whole peer-file replacement would drop our child-work tests. |
|
||||
| `behavior.rs` | Canvas adds camera_pan and pan/scale drawing. Our runtime lifecycle rendering and bounded header text are absent from that peer file. Keep both and adapt clip rectangle plus clipped child-text transform to pan AND scale; clipped frames reset their transform. |
|
||||
| `behavior/scenario_lifecycle/render_tests.rs` and renderer | Peer tree has no B4 render_tests and only its earlier projection module. Preserve our markers/selection/visible lifetimes. Create-label clipped child frames and hit coordinates need the same pan/scale mapping as the scene; scale-only tests cannot prove this union. |
|
||||
|
||||
Navigation itself does not change behavior.rs in its 18-path delta, so copying its older branch version would introduce needless B4 loss. Its App raw-input observer must compose with canvas/reference/scenario focus roots: observe raw events before nested widget/overlay capture while preserving current owner/revision/mount guards and chooser priority. A winner-takes-all App::view replacement loses one contribution. Captured Escape, modal ownership, source editor focus and canvas shortcuts are the highest-risk shared event routes. Preserve our lifecycle/contextual guide text when merging the independent canvas/navigation help additions.
|
||||
|
||||
Issue 3's later semantic drag must remain separate from transient camera movement. The published contract distinguishes layout-only moves with the same effective performer from explicit semantic incoming/reassign/outgoing drops with clean press/release admission and complete candidate validation. Mixed convergence and unsupported role exits refuse. Its private implementation cannot be inferred from branch names or filesystem paths.
|
||||
|
||||
## Next bounded step and genuinely new checks
|
||||
|
||||
Root has chosen to publish current evidence and defer integration. At the next source checkpoint, first pin the peer's ready canvas/chooser prerequisite closure and perform a dedicated hunk-level B4/header/child-work union, excluding analysis_numeric and preserving b2c form fixes. Obtain an independently reviewed diff before building. Wait for peer 9e8r6t's published four native fixes and matching results before adding navigation; compose the App observer/focus routes in that separate step. Do not import private issue 3 work or repeat its unexecuted-test claims as passes.
|
||||
|
||||
New union checks have concrete purposes:
|
||||
|
||||
- Run the retained child-work three tests, contextual-source and parent-focus/chooser tests to detect lost responsibility/source/session behavior; retain actual mounted App roots, overlay handling, message draining before returned Tasks, and stale-task assertions.
|
||||
- Add/run pan-aware B4 and header pixel/hit checks at nonzero pan and relevant zoom/minimum-window sizes, including Create capsule labels and clipped viewport edges. Test pan + scale on both clip and child text, not only scene geometry.
|
||||
- Run actual mounted canvas Fit/Tab/F6/palette/dirty/modal ownership cases; the peer's forced 1100×420 overflow case is not itself native 1100×700 acceptance.
|
||||
- For navigation union, exercise raw editor/Find/results/chooser/modal Escape and delayed Alt+Space input after the peer correction; dirty/stale query/reveal and explicit candidate selection must leave source/model history coherent.
|
||||
- A frozen combined desktop needs its own full all-target tests, strict desktop Clippy and matching packaged native workflow evidence. Prior 707/729/737 counts prove their recorded inputs only. Adding language.rs also requires the relevant core language/Unicode and core/server/CLI gates; do not relabel immutable older server ABA87A5F as a newly built navigation server.
|
||||
|
||||
## Evidence limits
|
||||
|
||||
This review used Gitea issue 3 through comment 472, issue 2 through 475, issue 6 through 482, published branch/commit metadata, exact blob contents and complete recursive crates subtrees (not truncated repository-wide inventories), local read-only Git/source comparisons, and relevant wiki pages (BehaviorCanvasPhase120260911, ScenarioFocus20260911, Authoring20260911). Older wiki execution-pending wording is historical; later issue results are scoped above. Peer execution is attributed, not independently repeated. No apply-check or compiler validation of a merged candidate was performed; current peer objects were not fetched into this checkout. Different filesystem views are established by peer coordination comments 473/477; matching Windows paths do not imply shared binaries, profiles or private working trees.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"schema": "arcadia.closed-source-review-readback.v1",
|
||||
"verified_utc": "2026-09-11T19:46:51.7527753Z",
|
||||
"manifest_sha256": "EDAA07071D15969009594A8C7A80E36590859D61383A6B1FCAB6F05F694F99F3",
|
||||
"payload_count": 7,
|
||||
"discrepancy_count": 0,
|
||||
"files": [
|
||||
{
|
||||
"destination": "canvas-prerequisite-union-report.md",
|
||||
"original_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9",
|
||||
"copy_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9",
|
||||
"original_bytes": 7652,
|
||||
"copy_bytes": 7652,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-renderer-union-report.md",
|
||||
"original_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B",
|
||||
"copy_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B",
|
||||
"original_bytes": 4147,
|
||||
"copy_bytes": 4147,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-pan-independent-review.md",
|
||||
"original_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D",
|
||||
"copy_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D",
|
||||
"original_bytes": 5592,
|
||||
"copy_bytes": 5592,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-root-prerequisite-final-review.md",
|
||||
"original_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48",
|
||||
"copy_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48",
|
||||
"original_bytes": 8560,
|
||||
"copy_bytes": 8560,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-renderer-final-review.md",
|
||||
"original_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4",
|
||||
"copy_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4",
|
||||
"original_bytes": 5870,
|
||||
"copy_bytes": 5870,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "next-integration-865483b-review.md",
|
||||
"original_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642",
|
||||
"copy_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642",
|
||||
"original_bytes": 13821,
|
||||
"copy_bytes": 13821,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "inputs.json",
|
||||
"original_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"copy_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"original_bytes": 101859,
|
||||
"copy_bytes": 101859,
|
||||
"matches_frozen_manifest": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
# Lifecycle header correction: scoped native observations
|
||||
|
||||
Source `865483b4c66361a01fe0d9e731859220bafb4136`; old extracted Windows Studio SHA256 `BF9C2E618D4A4AC55E6A7EBB3738736659FC35A6465522C7B5AB975238938808`, PID 8680/window 5702564. Root recorded the actual native workflow in `header-native-observations-20260911.json` at 2026-09-11 20:27:41.299 UTC. This archive does not establish a57c334 GUI acceptance.
|
||||
|
||||
**Observed wide Latin/CJK header text is bounded in the recorded cases. Unicode shortening is not fully accepted:** scalar truncation visibly drops a combining accent and splits a joined emoji sequence. The complete source names and identities remain unchanged. These are separate presentation findings; source equality does not establish correct shortened glyph appearance.
|
||||
|
||||
## Actual case coverage
|
||||
|
||||
The following is root's recorded native coverage, not an inferred four-name × size × zoom matrix. Native findings are attributed to the preserved observation record; this archiver independently checked image dimensions and evidence bytes, without issuing UI input or rerunning the oracle.
|
||||
|
||||
| Name/layer | Recorded observations |
|
||||
| --- | --- |
|
||||
| 18 W, OA | Wide 1442×932 at 100%; lane/Create identity selection; 195% partly viewport-clipped then horizontal scroll revealing contained header/card and expected selections. Actual minimum outer 1102×732, Fit 69%, small displayed 51% (51.2% actual) with both labels contained and expected lane/Create aliases. |
|
||||
| 18 界, SA | Minimum 100% with viewport clipping, horizontal scroll exposing bounded label/capsule, full name in Properties and expected lane/Create aliases; minimum Fit 69%; maximized Fit recomputed 100%; maximized 195% with bounded header/capsule but a final CJK glyph potentially cut at the inset. |
|
||||
| Combining-name, OA | At 100%, shortened label drops an accent at the scalar cutoff while remaining bounded. Lane Properties retains the full accented name and UUID `5ceab400-0000-4000-8000-00000000044e`; Create selects `life_oa_110`. No complete minimum/zoom matrix was run. |
|
||||
| Joined-emoji name, SA | At 100%, shortened joined sequence appears as a woman glyph plus ellipsis. Properties retains the full name and UUID `5ceab400-0000-4000-8000-000000000836`; Create selects `life_sa_110`. No complete minimum/zoom matrix was run. |
|
||||
|
||||
The preserved screenshot sequence is 61–95 inclusive. Screenshot 61 records the reported wide-fixture source-project open; 86 reports All source files saved. Screenshots 87–88 cover cluster filename entry/open, and 95 reports All source files saved.
|
||||
|
||||
Historical filename corrections are verified from the JPEG dimensions:
|
||||
|
||||
- `70-header-wide-oa-minimum-195.jpg` is **1442×932**, not minimum. The attempted drag moved the canvas scrollbar without resizing.
|
||||
- Screenshot 71 is **1102×932**: width changed, height still tall.
|
||||
- Screenshot 72 is **1102×732**, the confirmed minimum outer size.
|
||||
- The standard maximize action produced **2246×1232**; screenshot 84's Fit result is 100%, not a reused 69%.
|
||||
|
||||
Root also observed the old Scrollable retaining earlier offsets when changing from Fit back to 100%. Fit showed the complete scenario; returning to higher zoom restored prior clipping. That is separate from text escaping its card. The recovered resize attempts and exact observations remain unchanged in the JSON.
|
||||
|
||||
## Source and receipt verification
|
||||
|
||||
Four closed log/status pairs report exit 0 with identical corresponding PASS text, empty stderr, valid start/completion times and verified argument scopes. The before-admission receipts compare each original fixture with itself; the after-passive-save receipts compare its before/after files.
|
||||
|
||||
| Fixture | Complete before/after physical map | Passive-save receipt completion UTC |
|
||||
| --- | --- | --- |
|
||||
| header-wide | 2 files, 20,214 bytes, exact SHA256 equality | 20:24:46.466 |
|
||||
| header-clusters | 2 files, 20,196 bytes, exact SHA256 equality | 20:27:05.948 |
|
||||
|
||||
Each of the four adjacent checkpoint receipts matches its entire two-file directory. Existing oracle output reports all identities, fields, reachable source and physical bytes unchanged. This archiver independently repeated only physical-file/receipt comparisons, not parser/model-oracle execution. Full names in source were not shortened or repaired to obtain containment.
|
||||
|
||||
## Existing correction and predictions
|
||||
|
||||
The [header overflow correction record](../../lifecycle-header-overflow-correction-ytr83r-20260911.md) explains the fixed card inset, viewport intersection and child-frame transform. Its existing shortening rule remained unchanged, so clipping may cut a final glyph or hide the ellipsis; it was never complete grapheme-aware shortening.
|
||||
|
||||
The [typography fixture predictions](../lifecycle-native-ytr83r-20260911/typography-predictions.json) preserve exact four-name fixture changes, identity and byte predictions. The [historical typography review](../../release-lifecycle-typography-review-ytr83r-20260911.md) identified scalar-count and cluster-splitting risks before these observations. Those records keep their original historical scope; this archive supplies the later bounded native evidence without rewriting them.
|
||||
|
||||
## Frozen archive
|
||||
|
||||
56 payloads, **4,669,130 bytes**: 35 exact JPEG screenshots, eight closed log/status files, eight checkpoint source files, four adjacent checkpoint receipts and the immutable observation JSON. Filenames, extensions, CRLF/source bytes and historical labels are preserved.
|
||||
|
||||
`manifest.json` records every original path, byte size, original/copy SHA256 and original modification time. `verification.json` records the four receipt checks, both complete before/after comparisons, checkpoint receipts and all image dimensions. `readback.json` independently rereads all 56 original/copy payloads against the frozen manifest: zero discrepancies.
|
||||
|
||||
No binaries, ZIPs, live process logs, working directories, profiles or unrelated evidence were copied. No source, Git/index, compiler, Cargo, GUI, Gitea or WSL action occurred during archival. Comprehensive corrected grapheme appearance, remaining cluster samples, contextual workflows, packaged Home/Guide/close and new canvas native acceptance remain pending; no issue/release closure is claimed.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"created_utc": "2026-09-11T20:27:05.527Z",
|
||||
"source": "working-clusters",
|
||||
"checkpoint": "header-clusters-after",
|
||||
"files": [
|
||||
{
|
||||
"path": "project.arcadia",
|
||||
"bytes": 20110,
|
||||
"sha256": "eecddfc4a4514e995633ea58b89fe154dc6dc43509b1f53ee05b76f8e6b993c8"
|
||||
},
|
||||
{
|
||||
"path": "rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 "abcdefghijklmnopé尾" 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 "abcdefghijklmnop👩🔬 tail" 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,17 +0,0 @@
|
||||
{
|
||||
"created_utc": "2026-09-11T20:10:43.891Z",
|
||||
"source": "working-clusters",
|
||||
"checkpoint": "header-clusters-before",
|
||||
"files": [
|
||||
{
|
||||
"path": "project.arcadia",
|
||||
"bytes": 20110,
|
||||
"sha256": "eecddfc4a4514e995633ea58b89fe154dc6dc43509b1f53ee05b76f8e6b993c8"
|
||||
},
|
||||
{
|
||||
"path": "rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 "abcdefghijklmnopé尾" 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 "abcdefghijklmnop👩🔬 tail" 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,17 +0,0 @@
|
||||
{
|
||||
"created_utc": "2026-09-11T20:24:46.226Z",
|
||||
"source": "working-wide",
|
||||
"checkpoint": "header-wide-after",
|
||||
"files": [
|
||||
{
|
||||
"path": "project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6a18ee1f246321bdf8ab0e7e8e5d4eba134ce486ed5224c4ffd61c7342c3a7d3"
|
||||
},
|
||||
{
|
||||
"path": "rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,17 +0,0 @@
|
||||
{
|
||||
"created_utc": "2026-09-11T20:10:43.839Z",
|
||||
"source": "working-wide",
|
||||
"checkpoint": "header-wide-before",
|
||||
"files": [
|
||||
{
|
||||
"path": "project.arcadia",
|
||||
"bytes": 20128,
|
||||
"sha256": "6a18ee1f246321bdf8ab0e7e8e5d4eba134ce486ed5224c4ffd61c7342c3a7d3"
|
||||
},
|
||||
{
|
||||
"path": "rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"sha256": "8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,74 +0,0 @@
|
||||
{
|
||||
"owner": "Codex-ytr83r /root",
|
||||
"recorded_utc": "2026-09-11T20:27:41.299Z",
|
||||
"source": "865483b4c66361a01fe0d9e731859220bafb4136",
|
||||
"gui_sha256": "BF9C2E618D4A4AC55E6A7EBB3738736659FC35A6465522C7B5AB975238938808",
|
||||
"pid": 8680,
|
||||
"window": 5702564,
|
||||
"screenshots": "61 through 95 inclusive",
|
||||
"scope": "Actual old extracted Windows GUI; not a57 native acceptance or complete Unicode acceptance.",
|
||||
"wide": {
|
||||
"opened": "61 explicit Source project opened",
|
||||
"oa": [
|
||||
"63 normal100 wide1442x932 header and capsule contained",
|
||||
"64 lane life_oa_102 selected; full18W name available",
|
||||
"65 Create life_oa_110 selected",
|
||||
"66 enlarged195% header partly viewport-clipped",
|
||||
"67 horizontal scroll reveals complete header/card, no text beyond card",
|
||||
"68 scrolled lane and69 scrolled Create select expected aliases",
|
||||
"72 true minimum outer1102x732 confirmed",
|
||||
"74 Fit displayed69% shows complete scenario",
|
||||
"76 small51% (actual51.2%) contains both labels",
|
||||
"77 lane/78 Create retain expected aliases at small zoom"
|
||||
],
|
||||
"sa": [
|
||||
"79 minimum100, header clipped at viewport",
|
||||
"80 horizontal scroll reveals bounded CJK label and capsule",
|
||||
"81 lane life_sa_102 selected with full18界 in Properties",
|
||||
"82 Create life_sa_110 selected",
|
||||
"83 minimum Fit69% complete scenario",
|
||||
"84 maximized Fit recomputed100%",
|
||||
"85 maximized195% bounded header and capsule, final CJK glyph may be partially clipped at inset"
|
||||
],
|
||||
"save": "86 explicit All source files saved",
|
||||
"oracle": "header-wide-after-passive-save",
|
||||
"physical_files": 2,
|
||||
"physical_bytes": 20214,
|
||||
"before": "checkpoints/header-wide-before",
|
||||
"after": "checkpoints/header-wide-after"
|
||||
},
|
||||
"clusters": {
|
||||
"opened": "87 exact filename then88 Source project opened",
|
||||
"observations": [
|
||||
"89 OA100 shortened label drops combining accent at scalar cutoff; text bounded",
|
||||
"90 OA lane selected, full accented name and UUID ...044e in Properties",
|
||||
"91 OA Create selected alias life_oa_110",
|
||||
"92 SA100 shortened joined emoji appears as woman glyph plus ellipsis, rather than complete original joined sequence",
|
||||
"93 SA lane selected, full name and UUID ...0836 in Properties",
|
||||
"94 SA Create selected alias life_sa_110"
|
||||
],
|
||||
"save": "95 explicit All source files saved",
|
||||
"oracle": "header-clusters-after-passive-save",
|
||||
"physical_files": 2,
|
||||
"physical_bytes": 20196,
|
||||
"before": "checkpoints/header-clusters-before",
|
||||
"after": "checkpoints/header-clusters-after"
|
||||
},
|
||||
"findings": [
|
||||
"Header overflow correction visually contains wide Latin/CJK text in observed cases. Source/identities unchanged after passive Save for both complete fixtures.",
|
||||
"Unicode-shortening defect remains: scalar cutoff can split combining accents or joined emoji. Root requested independent source review. This prevents claiming complete Unicode presentation acceptance.",
|
||||
"Old Scrollable retained previous offsets when returning from Fit to100; Fit displays full diagram, higher zoom restores prior clipping. Separate from glyph bounds."
|
||||
],
|
||||
"recovered_attempts": [
|
||||
"Screenshot70 filename says minimum195 but drag did NOT resize; it moved the canvas scrollbar. It remains a1442x932 wide observation.",
|
||||
"Standard Alt+Space, s, Right, coordinate click adjusted width; repeated Alt+Space,s,Down,coordinate click adjusted height. Screenshot71 width1102 with tall932;72 true1102x732.",
|
||||
"Maximize via actual standard system menu produced2246x1232 screenshot and recomputed Fit100. Screenshot84 historically names maximized-fit and actual100."
|
||||
],
|
||||
"pending": [
|
||||
"Comprehensive corrected grapheme/native appearance, including remaining cluster minimum/zoom samples",
|
||||
"Combined staged contextual source workflows",
|
||||
"Packaged Home/Guide/example/normal-close checks",
|
||||
"Native execution of newa57GUI"
|
||||
],
|
||||
"linux": "Paused; no WSL/Linux actions"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"started_utc": "2026-09-11T20:27:05.583Z",
|
||||
"completed_utc": "2026-09-11T20:27:05.948Z",
|
||||
"kind": "lifecycle",
|
||||
"args": [
|
||||
"same",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-clusters-before/project.arcadia",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-clusters-after/project.arcadia"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "PASS same: all identities, fields, reachable source and physical bytes\n",
|
||||
"stderr": ""
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
PASS same: all identities, fields, reachable source and physical bytes
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"started_utc": "2026-09-11T20:10:44.521Z",
|
||||
"completed_utc": "2026-09-11T20:10:44.790Z",
|
||||
"kind": "lifecycle",
|
||||
"args": [
|
||||
"same",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-clusters-before/project.arcadia",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-clusters-before/project.arcadia"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "PASS same: all identities, fields, reachable source and physical bytes\n",
|
||||
"stderr": ""
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
PASS same: all identities, fields, reachable source and physical bytes
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"started_utc": "2026-09-11T20:24:46.229Z",
|
||||
"completed_utc": "2026-09-11T20:24:46.466Z",
|
||||
"kind": "lifecycle",
|
||||
"args": [
|
||||
"same",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-wide-before/project.arcadia",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-wide-after/project.arcadia"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "PASS same: all identities, fields, reachable source and physical bytes\n",
|
||||
"stderr": ""
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
PASS same: all identities, fields, reachable source and physical bytes
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"started_utc": "2026-09-11T20:10:43.894Z",
|
||||
"completed_utc": "2026-09-11T20:10:44.520Z",
|
||||
"kind": "lifecycle",
|
||||
"args": [
|
||||
"same",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-wide-before/project.arcadia",
|
||||
"C:/Users/User/Documents/Git/ARCADIA-Studio/.local/native-865483b/checkpoints/header-wide-before/project.arcadia"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "PASS same: all identities, fields, reachable source and physical bytes\n",
|
||||
"stderr": ""
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
PASS same: all identities, fields, reachable source and physical bytes
|
||||
@@ -1,514 +0,0 @@
|
||||
{
|
||||
"schema": "arcadia.closed-native-header-archive.v1",
|
||||
"captured_utc": "2026-09-11T20:30:08.9049564Z",
|
||||
"source_commit": "865483b4c66361a01fe0d9e731859220bafb4136",
|
||||
"gui_sha256": "BF9C2E618D4A4AC55E6A7EBB3738736659FC35A6465522C7B5AB975238938808",
|
||||
"payload_count": 56,
|
||||
"payload_bytes": 4669130.0,
|
||||
"files": [
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-clusters-after.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-clusters-after.json",
|
||||
"destination": "checkpoints/header-clusters-after.json",
|
||||
"bytes": 439,
|
||||
"original_last_write_utc": "2026-09-11T20:27:05.5535077Z",
|
||||
"original_sha256": "831E1A20A9F59AC3853D3229E616EA36219D43592EFB9E8B34F2354F0E0E3CBF",
|
||||
"copy_sha256": "831E1A20A9F59AC3853D3229E616EA36219D43592EFB9E8B34F2354F0E0E3CBF"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-clusters-after/project.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-clusters-after\\project.arcadia",
|
||||
"destination": "checkpoints/header-clusters-after/project.arcadia",
|
||||
"bytes": 20110,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4654996Z",
|
||||
"original_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"copy_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-clusters-after/rationale.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-clusters-after\\rationale.arcadia",
|
||||
"destination": "checkpoints/header-clusters-after/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4679088Z",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-clusters-before.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-clusters-before.json",
|
||||
"destination": "checkpoints/header-clusters-before.json",
|
||||
"bytes": 440,
|
||||
"original_last_write_utc": "2026-09-11T20:10:43.8947163Z",
|
||||
"original_sha256": "BFAC9EB68F4259F9FF94F77CB79A4B3B28D1B85817AB042F2579E61969626515",
|
||||
"copy_sha256": "BFAC9EB68F4259F9FF94F77CB79A4B3B28D1B85817AB042F2579E61969626515"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-clusters-before/project.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-clusters-before\\project.arcadia",
|
||||
"destination": "checkpoints/header-clusters-before/project.arcadia",
|
||||
"bytes": 20110,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4654996Z",
|
||||
"original_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"copy_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-clusters-before/rationale.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-clusters-before\\rationale.arcadia",
|
||||
"destination": "checkpoints/header-clusters-before/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4679088Z",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-wide-after.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-wide-after.json",
|
||||
"destination": "checkpoints/header-wide-after.json",
|
||||
"bytes": 431,
|
||||
"original_last_write_utc": "2026-09-11T20:24:46.2300085Z",
|
||||
"original_sha256": "EEE452D7B6C54E97D5059DEADB9F73AA2FDDA6494B08DA87726955F62F3ABCD7",
|
||||
"copy_sha256": "EEE452D7B6C54E97D5059DEADB9F73AA2FDDA6494B08DA87726955F62F3ABCD7"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-wide-after/project.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-wide-after\\project.arcadia",
|
||||
"destination": "checkpoints/header-wide-after/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4629284Z",
|
||||
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-wide-after/rationale.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-wide-after\\rationale.arcadia",
|
||||
"destination": "checkpoints/header-wide-after/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4629284Z",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-wide-before.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-wide-before.json",
|
||||
"destination": "checkpoints/header-wide-before.json",
|
||||
"bytes": 432,
|
||||
"original_last_write_utc": "2026-09-11T20:10:43.8397369Z",
|
||||
"original_sha256": "D86F59F96AC24E43A7102822F2276222350C23056ACBA9C9794C9C45C8961BA3",
|
||||
"copy_sha256": "D86F59F96AC24E43A7102822F2276222350C23056ACBA9C9794C9C45C8961BA3"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-wide-before/project.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-wide-before\\project.arcadia",
|
||||
"destination": "checkpoints/header-wide-before/project.arcadia",
|
||||
"bytes": 20128,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4629284Z",
|
||||
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/checkpoints/header-wide-before/rationale.arcadia",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\checkpoints\\header-wide-before\\rationale.arcadia",
|
||||
"destination": "checkpoints/header-wide-before/rationale.arcadia",
|
||||
"bytes": 86,
|
||||
"original_last_write_utc": "2026-09-11T13:49:36.4629284Z",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/header-native-observations-20260911.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\header-native-observations-20260911.json",
|
||||
"destination": "header-native-observations-20260911.json",
|
||||
"bytes": 3994,
|
||||
"original_last_write_utc": "2026-09-11T20:27:41.2977392Z",
|
||||
"original_sha256": "BA3D6D5825FD3E17FE50F21D42152681F8DAB1E1BE0A573BABFB6D0FCF9CF222",
|
||||
"copy_sha256": "BA3D6D5825FD3E17FE50F21D42152681F8DAB1E1BE0A573BABFB6D0FCF9CF222"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-clusters-after-passive-save-status.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-clusters-after-passive-save-status.json",
|
||||
"destination": "logs/header-clusters-after-passive-save-status.json",
|
||||
"bytes": 508,
|
||||
"original_last_write_utc": "2026-09-11T20:27:05.9488838Z",
|
||||
"original_sha256": "53836B6E16F07786C2CBD451AFE4348C8871760F0BDE88C0BE9A15708CC2FFDA",
|
||||
"copy_sha256": "53836B6E16F07786C2CBD451AFE4348C8871760F0BDE88C0BE9A15708CC2FFDA"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-clusters-after-passive-save.log",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-clusters-after-passive-save.log",
|
||||
"destination": "logs/header-clusters-after-passive-save.log",
|
||||
"bytes": 71,
|
||||
"original_last_write_utc": "2026-09-11T20:27:05.9475139Z",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-clusters-before-admission-status.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-clusters-before-admission-status.json",
|
||||
"destination": "logs/header-clusters-before-admission-status.json",
|
||||
"bytes": 509,
|
||||
"original_last_write_utc": "2026-09-11T20:10:44.7895442Z",
|
||||
"original_sha256": "0E7F128A6E77FAA01D6EEDF1825CE78967A0C1B7A5A68DA406B486EA74C4B7BD",
|
||||
"copy_sha256": "0E7F128A6E77FAA01D6EEDF1825CE78967A0C1B7A5A68DA406B486EA74C4B7BD"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-clusters-before-admission.log",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-clusters-before-admission.log",
|
||||
"destination": "logs/header-clusters-before-admission.log",
|
||||
"bytes": 71,
|
||||
"original_last_write_utc": "2026-09-11T20:10:44.7895442Z",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-wide-after-passive-save-status.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-wide-after-passive-save-status.json",
|
||||
"destination": "logs/header-wide-after-passive-save-status.json",
|
||||
"bytes": 500,
|
||||
"original_last_write_utc": "2026-09-11T20:24:46.4669304Z",
|
||||
"original_sha256": "BBD70C1A4848026E2537CA37EFD255F2731EF12158F4A43EA4CD40E16B08B2DE",
|
||||
"copy_sha256": "BBD70C1A4848026E2537CA37EFD255F2731EF12158F4A43EA4CD40E16B08B2DE"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-wide-after-passive-save.log",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-wide-after-passive-save.log",
|
||||
"destination": "logs/header-wide-after-passive-save.log",
|
||||
"bytes": 71,
|
||||
"original_last_write_utc": "2026-09-11T20:24:46.4669304Z",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-wide-before-admission-status.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-wide-before-admission-status.json",
|
||||
"destination": "logs/header-wide-before-admission-status.json",
|
||||
"bytes": 501,
|
||||
"original_last_write_utc": "2026-09-11T20:10:44.5207397Z",
|
||||
"original_sha256": "3A14047AB7C756DE9E9239D7B66CC4C85EAC6DFF2EFB36089B67E51F6F50A38C",
|
||||
"copy_sha256": "3A14047AB7C756DE9E9239D7B66CC4C85EAC6DFF2EFB36089B67E51F6F50A38C"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/logs/header-wide-before-admission.log",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\logs\\header-wide-before-admission.log",
|
||||
"destination": "logs/header-wide-before-admission.log",
|
||||
"bytes": 71,
|
||||
"original_last_write_utc": "2026-09-11T20:10:44.5207397Z",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/61-header-wide-opened.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\61-header-wide-opened.jpg",
|
||||
"destination": "screenshots/61-header-wide-opened.jpg",
|
||||
"bytes": 101692,
|
||||
"original_last_write_utc": "2026-09-11T20:11:43.1565988Z",
|
||||
"original_sha256": "57BC7BE67E3C9558F5D642E392F2BFEA47D9228581D395C5E170C111116DF8BD",
|
||||
"copy_sha256": "57BC7BE67E3C9558F5D642E392F2BFEA47D9228581D395C5E170C111116DF8BD"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/62-header-wide-oa-selected.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\62-header-wide-oa-selected.jpg",
|
||||
"destination": "screenshots/62-header-wide-oa-selected.jpg",
|
||||
"bytes": 112326,
|
||||
"original_last_write_utc": "2026-09-11T20:17:55.5049694Z",
|
||||
"original_sha256": "DE0A3335C998D01B9390F5CA55562CDF49939828673A996405591834A14599F6",
|
||||
"copy_sha256": "DE0A3335C998D01B9390F5CA55562CDF49939828673A996405591834A14599F6"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/63-header-wide-oa-canvas-100.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\63-header-wide-oa-canvas-100.jpg",
|
||||
"destination": "screenshots/63-header-wide-oa-canvas-100.jpg",
|
||||
"bytes": 126842,
|
||||
"original_last_write_utc": "2026-09-11T20:18:01.4470688Z",
|
||||
"original_sha256": "FC55B82951C88E598C5EDAE87123192B12BA7652A09FEB99B6355110AB19CC6B",
|
||||
"copy_sha256": "FC55B82951C88E598C5EDAE87123192B12BA7652A09FEB99B6355110AB19CC6B"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/64-header-wide-oa-lane-identity.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\64-header-wide-oa-lane-identity.jpg",
|
||||
"destination": "screenshots/64-header-wide-oa-lane-identity.jpg",
|
||||
"bytes": 134421,
|
||||
"original_last_write_utc": "2026-09-11T20:18:09.2799391Z",
|
||||
"original_sha256": "EF25D3DF0F62DF3ECBF02FA831CDD8CC060A8CCC678A6AAA4D3A02CF0DEF612B",
|
||||
"copy_sha256": "EF25D3DF0F62DF3ECBF02FA831CDD8CC060A8CCC678A6AAA4D3A02CF0DEF612B"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/65-header-wide-oa-create-identity.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\65-header-wide-oa-create-identity.jpg",
|
||||
"destination": "screenshots/65-header-wide-oa-create-identity.jpg",
|
||||
"bytes": 133651,
|
||||
"original_last_write_utc": "2026-09-11T20:18:18.7453913Z",
|
||||
"original_sha256": "EB566C215A3E90389E54E102FB4B3440EBBE08CD0BF64005690C4C09568A1089",
|
||||
"copy_sha256": "EB566C215A3E90389E54E102FB4B3440EBBE08CD0BF64005690C4C09568A1089"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/66-header-wide-oa-195.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\66-header-wide-oa-195.jpg",
|
||||
"destination": "screenshots/66-header-wide-oa-195.jpg",
|
||||
"bytes": 145646,
|
||||
"original_last_write_utc": "2026-09-11T20:18:45.1402186Z",
|
||||
"original_sha256": "98A9EDA8D5BE88F96F53A43F4ADA86CA446ECE33D62BF1ABFED3D915AEB062DD",
|
||||
"copy_sha256": "98A9EDA8D5BE88F96F53A43F4ADA86CA446ECE33D62BF1ABFED3D915AEB062DD"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/67-header-wide-oa-195-horizontal-scroll.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\67-header-wide-oa-195-horizontal-scroll.jpg",
|
||||
"destination": "screenshots/67-header-wide-oa-195-horizontal-scroll.jpg",
|
||||
"bytes": 148419,
|
||||
"original_last_write_utc": "2026-09-11T20:18:57.3808514Z",
|
||||
"original_sha256": "9F3EFDF35D39D49C8222ABE1660D7A420A30C8EB3C094F79E70A07AA6111A028",
|
||||
"copy_sha256": "9F3EFDF35D39D49C8222ABE1660D7A420A30C8EB3C094F79E70A07AA6111A028"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/68-header-wide-oa-scrolled-lane-hit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\68-header-wide-oa-scrolled-lane-hit.jpg",
|
||||
"destination": "screenshots/68-header-wide-oa-scrolled-lane-hit.jpg",
|
||||
"bytes": 149919,
|
||||
"original_last_write_utc": "2026-09-11T20:19:05.8084438Z",
|
||||
"original_sha256": "82A650C2A5ED606CEA7758A8B0E60EC8B4542E4A57405C0CDB2E1A92E39EDEFC",
|
||||
"copy_sha256": "82A650C2A5ED606CEA7758A8B0E60EC8B4542E4A57405C0CDB2E1A92E39EDEFC"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/69-header-wide-oa-scrolled-create-hit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\69-header-wide-oa-scrolled-create-hit.jpg",
|
||||
"destination": "screenshots/69-header-wide-oa-scrolled-create-hit.jpg",
|
||||
"bytes": 147942,
|
||||
"original_last_write_utc": "2026-09-11T20:19:14.8355986Z",
|
||||
"original_sha256": "A24ADFE8E10DD9F98CC1FAC7AE930341FBCC81622C728B8213A0B510528F00F0",
|
||||
"copy_sha256": "A24ADFE8E10DD9F98CC1FAC7AE930341FBCC81622C728B8213A0B510528F00F0"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/70-header-wide-oa-minimum-195.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\70-header-wide-oa-minimum-195.jpg",
|
||||
"destination": "screenshots/70-header-wide-oa-minimum-195.jpg",
|
||||
"bytes": 138362,
|
||||
"original_last_write_utc": "2026-09-11T20:19:24.4072813Z",
|
||||
"original_sha256": "55D1180569C7DBCB039F6D6FEC6AEEC3C4261511732DD7DFD690AD58FBB6A84B",
|
||||
"copy_sha256": "55D1180569C7DBCB039F6D6FEC6AEEC3C4261511732DD7DFD690AD58FBB6A84B"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/71-window-size-attempt.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\71-window-size-attempt.jpg",
|
||||
"destination": "screenshots/71-window-size-attempt.jpg",
|
||||
"bytes": 122798,
|
||||
"original_last_write_utc": "2026-09-11T20:20:09.1298363Z",
|
||||
"original_sha256": "DDEB53C155D62F3D26B1A6E05DD93D82AC9E46271C425B3FEEB86F8F6D4725ED",
|
||||
"copy_sha256": "DDEB53C155D62F3D26B1A6E05DD93D82AC9E46271C425B3FEEB86F8F6D4725ED"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/72-header-wide-oa-minimum-size-confirmed.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\72-header-wide-oa-minimum-size-confirmed.jpg",
|
||||
"destination": "screenshots/72-header-wide-oa-minimum-size-confirmed.jpg",
|
||||
"bytes": 100976,
|
||||
"original_last_write_utc": "2026-09-11T20:20:36.1354797Z",
|
||||
"original_sha256": "7F47054FFA78F236B981511E12CBE5365FA6C5C687367DA3702FAF3D7187D31A",
|
||||
"copy_sha256": "7F47054FFA78F236B981511E12CBE5365FA6C5C687367DA3702FAF3D7187D31A"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/73-header-wide-oa-minimum-100.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\73-header-wide-oa-minimum-100.jpg",
|
||||
"destination": "screenshots/73-header-wide-oa-minimum-100.jpg",
|
||||
"bytes": 103287,
|
||||
"original_last_write_utc": "2026-09-11T20:20:43.5635613Z",
|
||||
"original_sha256": "AC1554F04C8C49301C0D4167923127DC10EFBB3851E15481568F9FDA65645474",
|
||||
"copy_sha256": "AC1554F04C8C49301C0D4167923127DC10EFBB3851E15481568F9FDA65645474"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/74-header-wide-oa-minimum-fit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\74-header-wide-oa-minimum-fit.jpg",
|
||||
"destination": "screenshots/74-header-wide-oa-minimum-fit.jpg",
|
||||
"bytes": 100096,
|
||||
"original_last_write_utc": "2026-09-11T20:20:54.0418819Z",
|
||||
"original_sha256": "F4B08A9D62018CCD88746A5615E4073993AC4856267FA78FBFD50E06C8793865",
|
||||
"copy_sha256": "F4B08A9D62018CCD88746A5615E4073993AC4856267FA78FBFD50E06C8793865"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/75-header-wide-oa-minimum-100-reset.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\75-header-wide-oa-minimum-100-reset.jpg",
|
||||
"destination": "screenshots/75-header-wide-oa-minimum-100-reset.jpg",
|
||||
"bytes": 103340,
|
||||
"original_last_write_utc": "2026-09-11T20:21:01.4543382Z",
|
||||
"original_sha256": "F80B976A0E7A289ED31BE31CF74D80779DAC1529D3EA0013F0C0396CDDAFBEFC",
|
||||
"copy_sha256": "F80B976A0E7A289ED31BE31CF74D80779DAC1529D3EA0013F0C0396CDDAFBEFC"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/76-header-wide-oa-minimum-51.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\76-header-wide-oa-minimum-51.jpg",
|
||||
"destination": "screenshots/76-header-wide-oa-minimum-51.jpg",
|
||||
"bytes": 97127,
|
||||
"original_last_write_utc": "2026-09-11T20:21:37.7293215Z",
|
||||
"original_sha256": "236D1238760061C6BC1E7C1882A7D10AE4448392AD3848C7C81F2973A491304D",
|
||||
"copy_sha256": "236D1238760061C6BC1E7C1882A7D10AE4448392AD3848C7C81F2973A491304D"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/77-header-wide-oa-minimum-51-lane-hit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\77-header-wide-oa-minimum-51-lane-hit.jpg",
|
||||
"destination": "screenshots/77-header-wide-oa-minimum-51-lane-hit.jpg",
|
||||
"bytes": 97592,
|
||||
"original_last_write_utc": "2026-09-11T20:21:45.6150156Z",
|
||||
"original_sha256": "9B15D206AAF7E021B546003C82715914143FFB2A681C96DCC35E8B4D29BBC944",
|
||||
"copy_sha256": "9B15D206AAF7E021B546003C82715914143FFB2A681C96DCC35E8B4D29BBC944"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/78-header-wide-oa-minimum-51-create-hit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\78-header-wide-oa-minimum-51-create-hit.jpg",
|
||||
"destination": "screenshots/78-header-wide-oa-minimum-51-create-hit.jpg",
|
||||
"bytes": 97287,
|
||||
"original_last_write_utc": "2026-09-11T20:21:53.2968011Z",
|
||||
"original_sha256": "6DC157A1738481F514DC53C23CF4BD45A1201748CB0875588589C07A539E843A",
|
||||
"copy_sha256": "6DC157A1738481F514DC53C23CF4BD45A1201748CB0875588589C07A539E843A"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/79-header-wide-sa-layer.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\79-header-wide-sa-layer.jpg",
|
||||
"destination": "screenshots/79-header-wide-sa-layer.jpg",
|
||||
"bytes": 103689,
|
||||
"original_last_write_utc": "2026-09-11T20:22:03.0804140Z",
|
||||
"original_sha256": "7C98206320B2E99E695606C61A4DBCD741ADB0E63C05B71C3296290CA227367B",
|
||||
"copy_sha256": "7C98206320B2E99E695606C61A4DBCD741ADB0E63C05B71C3296290CA227367B"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/80-header-wide-sa-minimum-100-scroll.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\80-header-wide-sa-minimum-100-scroll.jpg",
|
||||
"destination": "screenshots/80-header-wide-sa-minimum-100-scroll.jpg",
|
||||
"bytes": 103657,
|
||||
"original_last_write_utc": "2026-09-11T20:22:36.2420168Z",
|
||||
"original_sha256": "31C2B028AE622F58B30C1043FE420CD1E0DC751565220E860A7F7516BEAB7C7B",
|
||||
"copy_sha256": "31C2B028AE622F58B30C1043FE420CD1E0DC751565220E860A7F7516BEAB7C7B"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/81-header-wide-sa-minimum-lane-hit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\81-header-wide-sa-minimum-lane-hit.jpg",
|
||||
"destination": "screenshots/81-header-wide-sa-minimum-lane-hit.jpg",
|
||||
"bytes": 108422,
|
||||
"original_last_write_utc": "2026-09-11T20:22:44.4177714Z",
|
||||
"original_sha256": "D86F6B4023BE68813C96251FBBAC453E23E362BA4235E6BE382122082E1BACAB",
|
||||
"copy_sha256": "D86F6B4023BE68813C96251FBBAC453E23E362BA4235E6BE382122082E1BACAB"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/82-header-wide-sa-minimum-create-hit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\82-header-wide-sa-minimum-create-hit.jpg",
|
||||
"destination": "screenshots/82-header-wide-sa-minimum-create-hit.jpg",
|
||||
"bytes": 106921,
|
||||
"original_last_write_utc": "2026-09-11T20:22:50.8256294Z",
|
||||
"original_sha256": "A700B6C4A44679D4C300F2D854BD55C69481196353642171101D83EFC7AFDDFE",
|
||||
"copy_sha256": "A700B6C4A44679D4C300F2D854BD55C69481196353642171101D83EFC7AFDDFE"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/83-header-wide-sa-minimum-fit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\83-header-wide-sa-minimum-fit.jpg",
|
||||
"destination": "screenshots/83-header-wide-sa-minimum-fit.jpg",
|
||||
"bytes": 100076,
|
||||
"original_last_write_utc": "2026-09-11T20:23:47.8408607Z",
|
||||
"original_sha256": "68318A0B34FBCF8934AB9B164A8ECFB0A5988F59CCC9B7CB9CBC5636032CE0DF",
|
||||
"copy_sha256": "68318A0B34FBCF8934AB9B164A8ECFB0A5988F59CCC9B7CB9CBC5636032CE0DF"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/84-header-wide-sa-maximized-fit.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\84-header-wide-sa-maximized-fit.jpg",
|
||||
"destination": "screenshots/84-header-wide-sa-maximized-fit.jpg",
|
||||
"bytes": 175342,
|
||||
"original_last_write_utc": "2026-09-11T20:24:06.3308603Z",
|
||||
"original_sha256": "CA41D3C7CF545703C1787758058CE0FC138757058E945D0092658944B7E8AC8A",
|
||||
"copy_sha256": "CA41D3C7CF545703C1787758058CE0FC138757058E945D0092658944B7E8AC8A"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/85-header-wide-sa-maximized-195.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\85-header-wide-sa-maximized-195.jpg",
|
||||
"destination": "screenshots/85-header-wide-sa-maximized-195.jpg",
|
||||
"bytes": 197643,
|
||||
"original_last_write_utc": "2026-09-11T20:24:28.5305620Z",
|
||||
"original_sha256": "A37C85205168E8D2088F1E997886F13DC24940B4BD6C3AE6F474373C09DBD576",
|
||||
"copy_sha256": "A37C85205168E8D2088F1E997886F13DC24940B4BD6C3AE6F474373C09DBD576"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/86-header-wide-passive-save.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\86-header-wide-passive-save.jpg",
|
||||
"destination": "screenshots/86-header-wide-passive-save.jpg",
|
||||
"bytes": 196044,
|
||||
"original_last_write_utc": "2026-09-11T20:24:37.9120527Z",
|
||||
"original_sha256": "77D95D9B869378FE37B499EA9F7EEE57BD86226318ABB0DA1AFA73FD332721B3",
|
||||
"copy_sha256": "77D95D9B869378FE37B499EA9F7EEE57BD86226318ABB0DA1AFA73FD332721B3"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/87-header-clusters-open-path.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\87-header-clusters-open-path.jpg",
|
||||
"destination": "screenshots/87-header-clusters-open-path.jpg",
|
||||
"bytes": 57022,
|
||||
"original_last_write_utc": "2026-09-11T20:25:33.0859318Z",
|
||||
"original_sha256": "7AAF6872D0590265F05B7BB31F60828256447D5EFC57B1B8C826D8FC4776F605",
|
||||
"copy_sha256": "7AAF6872D0590265F05B7BB31F60828256447D5EFC57B1B8C826D8FC4776F605"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/88-header-clusters-opened.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\88-header-clusters-opened.jpg",
|
||||
"destination": "screenshots/88-header-clusters-opened.jpg",
|
||||
"bytes": 127653,
|
||||
"original_last_write_utc": "2026-09-11T20:25:42.5360123Z",
|
||||
"original_sha256": "531368E26B188CAFCA2E95ABFB92254D42781D2228E08E7C6F8C7304D38EA744",
|
||||
"copy_sha256": "531368E26B188CAFCA2E95ABFB92254D42781D2228E08E7C6F8C7304D38EA744"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/89-header-clusters-oa-100.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\89-header-clusters-oa-100.jpg",
|
||||
"destination": "screenshots/89-header-clusters-oa-100.jpg",
|
||||
"bytes": 146950,
|
||||
"original_last_write_utc": "2026-09-11T20:25:49.9684146Z",
|
||||
"original_sha256": "51F91BCBE3D2531F8D6C804FB9EC37E45AD9F1E6019017C6B5269EECFB3D4A76",
|
||||
"copy_sha256": "51F91BCBE3D2531F8D6C804FB9EC37E45AD9F1E6019017C6B5269EECFB3D4A76"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/90-header-clusters-oa-lane-identity.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\90-header-clusters-oa-lane-identity.jpg",
|
||||
"destination": "screenshots/90-header-clusters-oa-lane-identity.jpg",
|
||||
"bytes": 164377,
|
||||
"original_last_write_utc": "2026-09-11T20:25:58.6805769Z",
|
||||
"original_sha256": "B4ED83DE7FF581CD73372FEA002A6B22D633728A24D192E954A009E0F540A485",
|
||||
"copy_sha256": "B4ED83DE7FF581CD73372FEA002A6B22D633728A24D192E954A009E0F540A485"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/91-header-clusters-oa-create-identity.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\91-header-clusters-oa-create-identity.jpg",
|
||||
"destination": "screenshots/91-header-clusters-oa-create-identity.jpg",
|
||||
"bytes": 174343,
|
||||
"original_last_write_utc": "2026-09-11T20:26:05.6561793Z",
|
||||
"original_sha256": "9519C75875498A8D9BA2EEBBD9970F80A297F726A9F10986EAE93410AB9EB7B2",
|
||||
"copy_sha256": "9519C75875498A8D9BA2EEBBD9970F80A297F726A9F10986EAE93410AB9EB7B2"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/92-header-clusters-sa-emoji-100.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\92-header-clusters-sa-emoji-100.jpg",
|
||||
"destination": "screenshots/92-header-clusters-sa-emoji-100.jpg",
|
||||
"bytes": 146641,
|
||||
"original_last_write_utc": "2026-09-11T20:26:12.5329017Z",
|
||||
"original_sha256": "03D9B0F3C2483051C93A3048B80C028BDF49B2DC0792792FD57943546F06E772",
|
||||
"copy_sha256": "03D9B0F3C2483051C93A3048B80C028BDF49B2DC0792792FD57943546F06E772"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/93-header-clusters-sa-lane-identity.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\93-header-clusters-sa-lane-identity.jpg",
|
||||
"destination": "screenshots/93-header-clusters-sa-lane-identity.jpg",
|
||||
"bytes": 163781,
|
||||
"original_last_write_utc": "2026-09-11T20:26:35.5594802Z",
|
||||
"original_sha256": "54D85BDD11BA8355C0E7ECB7CACE0877863AB6C70A1A6A7F0B09BC02CE0A6E54",
|
||||
"copy_sha256": "54D85BDD11BA8355C0E7ECB7CACE0877863AB6C70A1A6A7F0B09BC02CE0A6E54"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/94-header-clusters-sa-create-identity.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\94-header-clusters-sa-create-identity.jpg",
|
||||
"destination": "screenshots/94-header-clusters-sa-create-identity.jpg",
|
||||
"bytes": 173820,
|
||||
"original_last_write_utc": "2026-09-11T20:26:45.1054752Z",
|
||||
"original_sha256": "2C1FB5B3913D04317E69A68E3125DA3A8FB7C6589629F6E8F030BC5379D3D9DD",
|
||||
"copy_sha256": "2C1FB5B3913D04317E69A68E3125DA3A8FB7C6589629F6E8F030BC5379D3D9DD"
|
||||
},
|
||||
{
|
||||
"original": ".local/native-865483b/screenshots/95-header-clusters-passive-save.jpg",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\screenshots\\95-header-clusters-passive-save.jpg",
|
||||
"destination": "screenshots/95-header-clusters-passive-save.jpg",
|
||||
"bytes": 172168,
|
||||
"original_last_write_utc": "2026-09-11T20:26:57.1624291Z",
|
||||
"original_sha256": "BABA2C28B52E936272445509ECB7C8F79F20602C9790B29BD96A48DD7E3DCD69",
|
||||
"copy_sha256": "BABA2C28B52E936272445509ECB7C8F79F20602C9790B29BD96A48DD7E3DCD69"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
{
|
||||
"verified_utc": "2026-09-11T20:30:10.6094848Z",
|
||||
"manifest_sha256": "EBA1B71FC5DD406D6B38C5F39A246939EFB71F0868CF3F58EE383587432C6C88",
|
||||
"payload_count": 56,
|
||||
"discrepancy_count": 0,
|
||||
"files": [
|
||||
{
|
||||
"destination": "checkpoints/header-clusters-after.json",
|
||||
"original_sha256": "831E1A20A9F59AC3853D3229E616EA36219D43592EFB9E8B34F2354F0E0E3CBF",
|
||||
"copy_sha256": "831E1A20A9F59AC3853D3229E616EA36219D43592EFB9E8B34F2354F0E0E3CBF",
|
||||
"original_bytes": 439,
|
||||
"copy_bytes": 439,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-clusters-after/project.arcadia",
|
||||
"original_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"copy_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"original_bytes": 20110,
|
||||
"copy_bytes": 20110,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-clusters-after/rationale.arcadia",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"original_bytes": 86,
|
||||
"copy_bytes": 86,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-clusters-before.json",
|
||||
"original_sha256": "BFAC9EB68F4259F9FF94F77CB79A4B3B28D1B85817AB042F2579E61969626515",
|
||||
"copy_sha256": "BFAC9EB68F4259F9FF94F77CB79A4B3B28D1B85817AB042F2579E61969626515",
|
||||
"original_bytes": 440,
|
||||
"copy_bytes": 440,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-clusters-before/project.arcadia",
|
||||
"original_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"copy_sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8",
|
||||
"original_bytes": 20110,
|
||||
"copy_bytes": 20110,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-clusters-before/rationale.arcadia",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"original_bytes": 86,
|
||||
"copy_bytes": 86,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-wide-after.json",
|
||||
"original_sha256": "EEE452D7B6C54E97D5059DEADB9F73AA2FDDA6494B08DA87726955F62F3ABCD7",
|
||||
"copy_sha256": "EEE452D7B6C54E97D5059DEADB9F73AA2FDDA6494B08DA87726955F62F3ABCD7",
|
||||
"original_bytes": 431,
|
||||
"copy_bytes": 431,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-wide-after/project.arcadia",
|
||||
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"original_bytes": 20128,
|
||||
"copy_bytes": 20128,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-wide-after/rationale.arcadia",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"original_bytes": 86,
|
||||
"copy_bytes": 86,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-wide-before.json",
|
||||
"original_sha256": "D86F59F96AC24E43A7102822F2276222350C23056ACBA9C9794C9C45C8961BA3",
|
||||
"copy_sha256": "D86F59F96AC24E43A7102822F2276222350C23056ACBA9C9794C9C45C8961BA3",
|
||||
"original_bytes": 432,
|
||||
"copy_bytes": 432,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-wide-before/project.arcadia",
|
||||
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
|
||||
"original_bytes": 20128,
|
||||
"copy_bytes": 20128,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "checkpoints/header-wide-before/rationale.arcadia",
|
||||
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
|
||||
"original_bytes": 86,
|
||||
"copy_bytes": 86,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "header-native-observations-20260911.json",
|
||||
"original_sha256": "BA3D6D5825FD3E17FE50F21D42152681F8DAB1E1BE0A573BABFB6D0FCF9CF222",
|
||||
"copy_sha256": "BA3D6D5825FD3E17FE50F21D42152681F8DAB1E1BE0A573BABFB6D0FCF9CF222",
|
||||
"original_bytes": 3994,
|
||||
"copy_bytes": 3994,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-clusters-after-passive-save-status.json",
|
||||
"original_sha256": "53836B6E16F07786C2CBD451AFE4348C8871760F0BDE88C0BE9A15708CC2FFDA",
|
||||
"copy_sha256": "53836B6E16F07786C2CBD451AFE4348C8871760F0BDE88C0BE9A15708CC2FFDA",
|
||||
"original_bytes": 508,
|
||||
"copy_bytes": 508,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-clusters-after-passive-save.log",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"original_bytes": 71,
|
||||
"copy_bytes": 71,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-clusters-before-admission-status.json",
|
||||
"original_sha256": "0E7F128A6E77FAA01D6EEDF1825CE78967A0C1B7A5A68DA406B486EA74C4B7BD",
|
||||
"copy_sha256": "0E7F128A6E77FAA01D6EEDF1825CE78967A0C1B7A5A68DA406B486EA74C4B7BD",
|
||||
"original_bytes": 509,
|
||||
"copy_bytes": 509,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-clusters-before-admission.log",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"original_bytes": 71,
|
||||
"copy_bytes": 71,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-wide-after-passive-save-status.json",
|
||||
"original_sha256": "BBD70C1A4848026E2537CA37EFD255F2731EF12158F4A43EA4CD40E16B08B2DE",
|
||||
"copy_sha256": "BBD70C1A4848026E2537CA37EFD255F2731EF12158F4A43EA4CD40E16B08B2DE",
|
||||
"original_bytes": 500,
|
||||
"copy_bytes": 500,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-wide-after-passive-save.log",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"original_bytes": 71,
|
||||
"copy_bytes": 71,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-wide-before-admission-status.json",
|
||||
"original_sha256": "3A14047AB7C756DE9E9239D7B66CC4C85EAC6DFF2EFB36089B67E51F6F50A38C",
|
||||
"copy_sha256": "3A14047AB7C756DE9E9239D7B66CC4C85EAC6DFF2EFB36089B67E51F6F50A38C",
|
||||
"original_bytes": 501,
|
||||
"copy_bytes": 501,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "logs/header-wide-before-admission.log",
|
||||
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
|
||||
"original_bytes": 71,
|
||||
"copy_bytes": 71,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/61-header-wide-opened.jpg",
|
||||
"original_sha256": "57BC7BE67E3C9558F5D642E392F2BFEA47D9228581D395C5E170C111116DF8BD",
|
||||
"copy_sha256": "57BC7BE67E3C9558F5D642E392F2BFEA47D9228581D395C5E170C111116DF8BD",
|
||||
"original_bytes": 101692,
|
||||
"copy_bytes": 101692,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/62-header-wide-oa-selected.jpg",
|
||||
"original_sha256": "DE0A3335C998D01B9390F5CA55562CDF49939828673A996405591834A14599F6",
|
||||
"copy_sha256": "DE0A3335C998D01B9390F5CA55562CDF49939828673A996405591834A14599F6",
|
||||
"original_bytes": 112326,
|
||||
"copy_bytes": 112326,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/63-header-wide-oa-canvas-100.jpg",
|
||||
"original_sha256": "FC55B82951C88E598C5EDAE87123192B12BA7652A09FEB99B6355110AB19CC6B",
|
||||
"copy_sha256": "FC55B82951C88E598C5EDAE87123192B12BA7652A09FEB99B6355110AB19CC6B",
|
||||
"original_bytes": 126842,
|
||||
"copy_bytes": 126842,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/64-header-wide-oa-lane-identity.jpg",
|
||||
"original_sha256": "EF25D3DF0F62DF3ECBF02FA831CDD8CC060A8CCC678A6AAA4D3A02CF0DEF612B",
|
||||
"copy_sha256": "EF25D3DF0F62DF3ECBF02FA831CDD8CC060A8CCC678A6AAA4D3A02CF0DEF612B",
|
||||
"original_bytes": 134421,
|
||||
"copy_bytes": 134421,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/65-header-wide-oa-create-identity.jpg",
|
||||
"original_sha256": "EB566C215A3E90389E54E102FB4B3440EBBE08CD0BF64005690C4C09568A1089",
|
||||
"copy_sha256": "EB566C215A3E90389E54E102FB4B3440EBBE08CD0BF64005690C4C09568A1089",
|
||||
"original_bytes": 133651,
|
||||
"copy_bytes": 133651,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/66-header-wide-oa-195.jpg",
|
||||
"original_sha256": "98A9EDA8D5BE88F96F53A43F4ADA86CA446ECE33D62BF1ABFED3D915AEB062DD",
|
||||
"copy_sha256": "98A9EDA8D5BE88F96F53A43F4ADA86CA446ECE33D62BF1ABFED3D915AEB062DD",
|
||||
"original_bytes": 145646,
|
||||
"copy_bytes": 145646,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/67-header-wide-oa-195-horizontal-scroll.jpg",
|
||||
"original_sha256": "9F3EFDF35D39D49C8222ABE1660D7A420A30C8EB3C094F79E70A07AA6111A028",
|
||||
"copy_sha256": "9F3EFDF35D39D49C8222ABE1660D7A420A30C8EB3C094F79E70A07AA6111A028",
|
||||
"original_bytes": 148419,
|
||||
"copy_bytes": 148419,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/68-header-wide-oa-scrolled-lane-hit.jpg",
|
||||
"original_sha256": "82A650C2A5ED606CEA7758A8B0E60EC8B4542E4A57405C0CDB2E1A92E39EDEFC",
|
||||
"copy_sha256": "82A650C2A5ED606CEA7758A8B0E60EC8B4542E4A57405C0CDB2E1A92E39EDEFC",
|
||||
"original_bytes": 149919,
|
||||
"copy_bytes": 149919,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/69-header-wide-oa-scrolled-create-hit.jpg",
|
||||
"original_sha256": "A24ADFE8E10DD9F98CC1FAC7AE930341FBCC81622C728B8213A0B510528F00F0",
|
||||
"copy_sha256": "A24ADFE8E10DD9F98CC1FAC7AE930341FBCC81622C728B8213A0B510528F00F0",
|
||||
"original_bytes": 147942,
|
||||
"copy_bytes": 147942,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/70-header-wide-oa-minimum-195.jpg",
|
||||
"original_sha256": "55D1180569C7DBCB039F6D6FEC6AEEC3C4261511732DD7DFD690AD58FBB6A84B",
|
||||
"copy_sha256": "55D1180569C7DBCB039F6D6FEC6AEEC3C4261511732DD7DFD690AD58FBB6A84B",
|
||||
"original_bytes": 138362,
|
||||
"copy_bytes": 138362,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/71-window-size-attempt.jpg",
|
||||
"original_sha256": "DDEB53C155D62F3D26B1A6E05DD93D82AC9E46271C425B3FEEB86F8F6D4725ED",
|
||||
"copy_sha256": "DDEB53C155D62F3D26B1A6E05DD93D82AC9E46271C425B3FEEB86F8F6D4725ED",
|
||||
"original_bytes": 122798,
|
||||
"copy_bytes": 122798,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/72-header-wide-oa-minimum-size-confirmed.jpg",
|
||||
"original_sha256": "7F47054FFA78F236B981511E12CBE5365FA6C5C687367DA3702FAF3D7187D31A",
|
||||
"copy_sha256": "7F47054FFA78F236B981511E12CBE5365FA6C5C687367DA3702FAF3D7187D31A",
|
||||
"original_bytes": 100976,
|
||||
"copy_bytes": 100976,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/73-header-wide-oa-minimum-100.jpg",
|
||||
"original_sha256": "AC1554F04C8C49301C0D4167923127DC10EFBB3851E15481568F9FDA65645474",
|
||||
"copy_sha256": "AC1554F04C8C49301C0D4167923127DC10EFBB3851E15481568F9FDA65645474",
|
||||
"original_bytes": 103287,
|
||||
"copy_bytes": 103287,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/74-header-wide-oa-minimum-fit.jpg",
|
||||
"original_sha256": "F4B08A9D62018CCD88746A5615E4073993AC4856267FA78FBFD50E06C8793865",
|
||||
"copy_sha256": "F4B08A9D62018CCD88746A5615E4073993AC4856267FA78FBFD50E06C8793865",
|
||||
"original_bytes": 100096,
|
||||
"copy_bytes": 100096,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/75-header-wide-oa-minimum-100-reset.jpg",
|
||||
"original_sha256": "F80B976A0E7A289ED31BE31CF74D80779DAC1529D3EA0013F0C0396CDDAFBEFC",
|
||||
"copy_sha256": "F80B976A0E7A289ED31BE31CF74D80779DAC1529D3EA0013F0C0396CDDAFBEFC",
|
||||
"original_bytes": 103340,
|
||||
"copy_bytes": 103340,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/76-header-wide-oa-minimum-51.jpg",
|
||||
"original_sha256": "236D1238760061C6BC1E7C1882A7D10AE4448392AD3848C7C81F2973A491304D",
|
||||
"copy_sha256": "236D1238760061C6BC1E7C1882A7D10AE4448392AD3848C7C81F2973A491304D",
|
||||
"original_bytes": 97127,
|
||||
"copy_bytes": 97127,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/77-header-wide-oa-minimum-51-lane-hit.jpg",
|
||||
"original_sha256": "9B15D206AAF7E021B546003C82715914143FFB2A681C96DCC35E8B4D29BBC944",
|
||||
"copy_sha256": "9B15D206AAF7E021B546003C82715914143FFB2A681C96DCC35E8B4D29BBC944",
|
||||
"original_bytes": 97592,
|
||||
"copy_bytes": 97592,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/78-header-wide-oa-minimum-51-create-hit.jpg",
|
||||
"original_sha256": "6DC157A1738481F514DC53C23CF4BD45A1201748CB0875588589C07A539E843A",
|
||||
"copy_sha256": "6DC157A1738481F514DC53C23CF4BD45A1201748CB0875588589C07A539E843A",
|
||||
"original_bytes": 97287,
|
||||
"copy_bytes": 97287,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/79-header-wide-sa-layer.jpg",
|
||||
"original_sha256": "7C98206320B2E99E695606C61A4DBCD741ADB0E63C05B71C3296290CA227367B",
|
||||
"copy_sha256": "7C98206320B2E99E695606C61A4DBCD741ADB0E63C05B71C3296290CA227367B",
|
||||
"original_bytes": 103689,
|
||||
"copy_bytes": 103689,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/80-header-wide-sa-minimum-100-scroll.jpg",
|
||||
"original_sha256": "31C2B028AE622F58B30C1043FE420CD1E0DC751565220E860A7F7516BEAB7C7B",
|
||||
"copy_sha256": "31C2B028AE622F58B30C1043FE420CD1E0DC751565220E860A7F7516BEAB7C7B",
|
||||
"original_bytes": 103657,
|
||||
"copy_bytes": 103657,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/81-header-wide-sa-minimum-lane-hit.jpg",
|
||||
"original_sha256": "D86F6B4023BE68813C96251FBBAC453E23E362BA4235E6BE382122082E1BACAB",
|
||||
"copy_sha256": "D86F6B4023BE68813C96251FBBAC453E23E362BA4235E6BE382122082E1BACAB",
|
||||
"original_bytes": 108422,
|
||||
"copy_bytes": 108422,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/82-header-wide-sa-minimum-create-hit.jpg",
|
||||
"original_sha256": "A700B6C4A44679D4C300F2D854BD55C69481196353642171101D83EFC7AFDDFE",
|
||||
"copy_sha256": "A700B6C4A44679D4C300F2D854BD55C69481196353642171101D83EFC7AFDDFE",
|
||||
"original_bytes": 106921,
|
||||
"copy_bytes": 106921,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/83-header-wide-sa-minimum-fit.jpg",
|
||||
"original_sha256": "68318A0B34FBCF8934AB9B164A8ECFB0A5988F59CCC9B7CB9CBC5636032CE0DF",
|
||||
"copy_sha256": "68318A0B34FBCF8934AB9B164A8ECFB0A5988F59CCC9B7CB9CBC5636032CE0DF",
|
||||
"original_bytes": 100076,
|
||||
"copy_bytes": 100076,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/84-header-wide-sa-maximized-fit.jpg",
|
||||
"original_sha256": "CA41D3C7CF545703C1787758058CE0FC138757058E945D0092658944B7E8AC8A",
|
||||
"copy_sha256": "CA41D3C7CF545703C1787758058CE0FC138757058E945D0092658944B7E8AC8A",
|
||||
"original_bytes": 175342,
|
||||
"copy_bytes": 175342,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/85-header-wide-sa-maximized-195.jpg",
|
||||
"original_sha256": "A37C85205168E8D2088F1E997886F13DC24940B4BD6C3AE6F474373C09DBD576",
|
||||
"copy_sha256": "A37C85205168E8D2088F1E997886F13DC24940B4BD6C3AE6F474373C09DBD576",
|
||||
"original_bytes": 197643,
|
||||
"copy_bytes": 197643,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/86-header-wide-passive-save.jpg",
|
||||
"original_sha256": "77D95D9B869378FE37B499EA9F7EEE57BD86226318ABB0DA1AFA73FD332721B3",
|
||||
"copy_sha256": "77D95D9B869378FE37B499EA9F7EEE57BD86226318ABB0DA1AFA73FD332721B3",
|
||||
"original_bytes": 196044,
|
||||
"copy_bytes": 196044,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/87-header-clusters-open-path.jpg",
|
||||
"original_sha256": "7AAF6872D0590265F05B7BB31F60828256447D5EFC57B1B8C826D8FC4776F605",
|
||||
"copy_sha256": "7AAF6872D0590265F05B7BB31F60828256447D5EFC57B1B8C826D8FC4776F605",
|
||||
"original_bytes": 57022,
|
||||
"copy_bytes": 57022,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/88-header-clusters-opened.jpg",
|
||||
"original_sha256": "531368E26B188CAFCA2E95ABFB92254D42781D2228E08E7C6F8C7304D38EA744",
|
||||
"copy_sha256": "531368E26B188CAFCA2E95ABFB92254D42781D2228E08E7C6F8C7304D38EA744",
|
||||
"original_bytes": 127653,
|
||||
"copy_bytes": 127653,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/89-header-clusters-oa-100.jpg",
|
||||
"original_sha256": "51F91BCBE3D2531F8D6C804FB9EC37E45AD9F1E6019017C6B5269EECFB3D4A76",
|
||||
"copy_sha256": "51F91BCBE3D2531F8D6C804FB9EC37E45AD9F1E6019017C6B5269EECFB3D4A76",
|
||||
"original_bytes": 146950,
|
||||
"copy_bytes": 146950,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/90-header-clusters-oa-lane-identity.jpg",
|
||||
"original_sha256": "B4ED83DE7FF581CD73372FEA002A6B22D633728A24D192E954A009E0F540A485",
|
||||
"copy_sha256": "B4ED83DE7FF581CD73372FEA002A6B22D633728A24D192E954A009E0F540A485",
|
||||
"original_bytes": 164377,
|
||||
"copy_bytes": 164377,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/91-header-clusters-oa-create-identity.jpg",
|
||||
"original_sha256": "9519C75875498A8D9BA2EEBBD9970F80A297F726A9F10986EAE93410AB9EB7B2",
|
||||
"copy_sha256": "9519C75875498A8D9BA2EEBBD9970F80A297F726A9F10986EAE93410AB9EB7B2",
|
||||
"original_bytes": 174343,
|
||||
"copy_bytes": 174343,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/92-header-clusters-sa-emoji-100.jpg",
|
||||
"original_sha256": "03D9B0F3C2483051C93A3048B80C028BDF49B2DC0792792FD57943546F06E772",
|
||||
"copy_sha256": "03D9B0F3C2483051C93A3048B80C028BDF49B2DC0792792FD57943546F06E772",
|
||||
"original_bytes": 146641,
|
||||
"copy_bytes": 146641,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/93-header-clusters-sa-lane-identity.jpg",
|
||||
"original_sha256": "54D85BDD11BA8355C0E7ECB7CACE0877863AB6C70A1A6A7F0B09BC02CE0A6E54",
|
||||
"copy_sha256": "54D85BDD11BA8355C0E7ECB7CACE0877863AB6C70A1A6A7F0B09BC02CE0A6E54",
|
||||
"original_bytes": 163781,
|
||||
"copy_bytes": 163781,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/94-header-clusters-sa-create-identity.jpg",
|
||||
"original_sha256": "2C1FB5B3913D04317E69A68E3125DA3A8FB7C6589629F6E8F030BC5379D3D9DD",
|
||||
"copy_sha256": "2C1FB5B3913D04317E69A68E3125DA3A8FB7C6589629F6E8F030BC5379D3D9DD",
|
||||
"original_bytes": 173820,
|
||||
"copy_bytes": 173820,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "screenshots/95-header-clusters-passive-save.jpg",
|
||||
"original_sha256": "BABA2C28B52E936272445509ECB7C8F79F20602C9790B29BD96A48DD7E3DCD69",
|
||||
"copy_sha256": "BABA2C28B52E936272445509ECB7C8F79F20602C9790B29BD96A48DD7E3DCD69",
|
||||
"original_bytes": 172168,
|
||||
"copy_bytes": 172168,
|
||||
"matches_frozen_manifest": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 95 KiB |