Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu 44fca0c041 Sync agent instructions from main 2026-09-13 11:38:20 +03:00
sorokin.oleg.edu fadff405b8 Sync agent instructions from main 2026-09-12 13:43:09 +03:00
sorokin.oleg.edu 4994994ee0 Sync agent instructions from main 2026-09-12 13:39:51 +03:00
sorokin.oleg.edu 20e3b824de Sync agent instructions from main 2026-09-12 13:15:19 +03:00
sorokin.oleg.edu fa62e90a9a Sync agent instructions from main 2026-09-11 19:51:28 +03:00
sorokin.oleg.edu 9fd9107c1c Sync agent instructions from main 2026-09-11 15:54:56 +03:00
sorokin.oleg.edu 234e01e27d Preserve exact frozen authoring milestone input bytes 2026-09-11 05:16:49 -07:00
sorokin.oleg.edu 2ec72d4241 Verify native behavior creation recovery and source authoring contracts 2026-09-11 04:21:56 -07:00
sorokin.oleg.edu 4bb4aaf3dc Add named behavior creation and verified textual authoring examples 2026-09-11 01:54:55 -07:00
sorokin.oleg.edu d114a271c5 Refresh scenario viewport measurement when its identity changes 2026-09-11 01:21:24 -07:00
sorokin.oleg.edu 3978bbe23c Keep scenario authoring visible and restore exact caller context 2026-09-10 12:03:17 -07:00
Codex 81668f94ed Seed optional history values from explicit constructors 2026-09-10 11:52:36 -07:00
sorokin.oleg.edu ee97fcb1e3 Record updated usability objective and integration evidence 2026-09-10 11:40:45 -07:00
sorokin.oleg.edu b98f135799 Add portable Linux packaging and verify distribution notices 2026-09-10 11:38:10 -07:00
sorokin.oleg.edu a70de55ecd Integrate nullable stored-history constructor correction 2026-09-10 11:25:59 -07:00
sorokin.oleg.edu 31b7dc3e15 Restore inline naming focus after recovery 2026-09-10 11:25:06 -07:00
sorokin.oleg.edu 057edddef8 Integrate process, scenario and typed history authoring checkpoint 2026-09-10 11:14:43 -07:00
sorokin.oleg.edu b24a97360f Record release evidence and coordinated integration work 2026-09-10 11:13:11 -07:00
sorokin.oleg.edu 7d1df4f369 Fix inline architecture naming and captured Escape cancellation
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-10 11:10:24 -07:00
605 changed files with 18192 additions and 160951 deletions
+17 -36
View File
@@ -15,52 +15,33 @@
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
PROJECT_GOAL.md -text whitespace=cr-at-eol
harness/fixtures/release1-native/** -text
harness/fixtures/interface-operation-probe/** -text
harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
# Preserve checksum-bound font notices verbatim through Git round trips.
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
harness/experiments/analysis-numeric-gate-20260911/** -text
harness/experiments/chooser-correction-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/chooser-lint-gate-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/history/2026-09-11-behavior-wiki-before-refresh.md -text whitespace=cr-at-eol
# Preserve the independently fingerprinted historical review verbatim.
harness/release-behavior-canvas-phase1-review-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-3c9e6ee-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-3c9e6ee-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/analysis-codec-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/history/2026-09-11-ledgers-before-gitea-migration/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/canvas-union-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/canvas-a31b40d-windows-native-20260911/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/allocation-d9b3f0d-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
# Preserve exact inputs of the frozen D4D9 authoring milestone build.
crates/oa-desktop/src/semantic_review/artifacts_view.rs -text
crates/oa-desktop/src/semantic_review/attachments_view.rs -text
crates/oa-desktop/src/semantic_review/keyed_rows.rs -text
crates/oa-desktop/src/semantic_review/keyed_tests.rs -text
crates/oa-desktop/src/semantic_review/keyed.rs -text
crates/oa-desktop/src/semantic_review/ordered.rs -text
docs/openapi.json -text
Generated
-31
View File
@@ -2107,25 +2107,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2158,23 +2139,11 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "oa-cli"
version = "0.2.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"num-bigint",
"num-integer",
"num-traits",
"same-file",
"serde",
"serde_json",
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
resolver = "2"
[workspace.package]
+1 -16
View File
@@ -18,7 +18,7 @@ From the repository root:
cargo run --locked -p oa-desktop --bin arcadia-studio
```
Build the desktop, server and source CLI release executables:
Build both desktop and server release executables:
```sh
cargo build --release --workspace --locked
@@ -51,21 +51,6 @@ The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `di
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
## Work with source files from the command line
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
starting the desktop or server:
```sh
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
```
`stabilize ROOT` previews the identities needed for persistent editing;
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
`--write` to save that invocation's candidate, or `--json` for structured
diagnostics and changed-file text/diffs. Preview leaves files untouched.
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
## Model operational needs and system responsibilities
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "oa-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "arcadia"
path = "src/main.rs"
[dependencies]
oa-core = { path = "../oa-core" }
serde.workspace = true
serde_json.workspace = true
-451
View File
@@ -1,451 +0,0 @@
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
use oa_core::{
Command, language,
persistence::MigrationReport,
prepare_command,
source_files::{self, DiskProject, SourceFileError},
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
validation::Severity,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
pub const LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1024,
model: language::ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
},
};
#[derive(Debug, Clone)]
pub enum Operation {
Check,
Stabilize,
RenameAlias { old: String, new: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct Location {
pub path: String,
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
pub start: usize,
pub end: usize,
pub line: Option<usize>,
pub column: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub severity: String,
pub code: String,
pub message: String,
pub coordinate_space: String,
pub location: Option<Location>,
pub related: Vec<Location>,
pub object_id: Option<String>,
pub field: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Change {
pub path: String,
/// Exact captured and proposed UTF-8 text, including line endings.
pub before: String,
pub after: String,
pub diff: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub format_version: u32,
pub entry: String,
pub operation: String,
pub status: String,
pub exit_code: u8,
/// True only after a successful changed save. False on failure does not
/// promise rollback: consult the specific error/recovery outcome.
pub written: bool,
pub diagnostics: Vec<Diagnostic>,
pub changes: Vec<Change>,
pub identities_to_stabilize: usize,
pub migration: Option<MigrationReport>,
pub notes: Vec<String>,
}
impl Report {
pub fn failure(
entry: &Path,
operation: &str,
exit_code: u8,
code: &str,
message: String,
) -> Self {
let mut report = Self::empty(entry, operation);
report.fail(exit_code, code, message);
report
}
fn empty(entry: &Path, operation: &str) -> Self {
Self {
format_version: 1,
entry: entry.to_string_lossy().into_owned(),
operation: operation.into(),
status: "checked".into(),
exit_code: 0,
written: false,
diagnostics: vec![],
changes: vec![],
identities_to_stabilize: 0,
migration: None,
notes: vec![],
}
}
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
self.status = "rejected".into();
self.exit_code = exit_code;
self.diagnostics.push(Diagnostic {
severity: "error".into(),
code: code.into(),
message,
coordinate_space: "operation".into(),
location: None,
related: vec![],
object_id: None,
field: None,
});
}
}
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
let d = issue.diagnostic;
Diagnostic {
severity: match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
}
.into(),
code: d.code,
message: d.message,
coordinate_space: space.into(),
location: Some(Location {
path: issue.path,
start: d.span.start,
end: d.span.end,
line: Some(d.line),
column: Some(d.column),
}),
related: issue
.related
.into_iter()
.map(|at| Location {
path: at.path,
start: at.span.start,
end: at.span.end,
line: None,
column: None,
})
.collect(),
object_id: d.object_id.map(|id| id.to_string()),
field: d.field,
}
}
fn disk_error(report: &mut Report, error: SourceFileError) {
let code = match &error {
SourceFileError::Conflict(_) => "external_change",
SourceFileError::RecoveryRequired(_) => "recovery_required",
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
SourceFileError::Path(_) => "unsafe_path",
SourceFileError::Limit(_) => "source_limit",
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
SourceFileError::Invalid(_) => "invalid_save_candidate",
SourceFileError::Io { .. } => "io_error",
};
report.fail(3, code, error.to_string());
}
/// A candidate owns its original expectations and is consumed by saving.
/// Callers cannot replace its source/model/expectations after preparation.
pub struct Prepared {
report: Report,
snapshot: DiskProject,
compiled: CompiledSourceProject,
mutable: bool,
}
impl Prepared {
pub fn report(&self) -> &Report {
&self.report
}
pub fn into_report(self) -> Report {
self.report
}
pub fn write(mut self) -> Report {
if !self.mutable {
self.report
.fail(2, "usage", "check/lint does not accept --write".into());
return self.report;
}
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
Ok(_) => {
self.report.written = !self.report.changes.is_empty();
self.report.status = if self.report.written {
"written"
} else {
"unchanged"
}
.into();
}
Err(error) => disk_error(&mut self.report, error),
}
self.report
}
}
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
let name = match operation {
Operation::Check => "check",
Operation::Stabilize => "stabilize",
Operation::RenameAlias { .. } => "rename-alias",
};
let mut report = Report::empty(entry, name);
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
disk_error(&mut report, error);
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
.filter(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save"
| "source_encoding"
| "source_limit"
| "invalid_path"
| "missing_include"
| "project_limit"
)
})
.cloned()
.collect();
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
if !diagnostics.contains(&diagnostic) {
diagnostics.push(diagnostic);
}
}
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save" | "missing_include" | "invalid_path"
)
});
let invalid = diagnostics
.iter()
.any(|d| d.diagnostic.severity == Severity::Error);
report.diagnostics = diagnostics
.into_iter()
.map(|d| located(d, "input"))
.collect();
if invalid {
// Do not run declaration analysis or any updater on invalid raw text.
// Failed lint may also have allocated provisional object identities.
for diagnostic in &mut report.diagnostics {
diagnostic.object_id = None;
}
report.status = "invalid".into();
report.exit_code = if disk_refusal { 3 } else { 1 };
return Err(Box::new(report));
}
let explicit_ids: std::collections::BTreeSet<_> = raw
.files
.values()
.flat_map(|text| language::analyze(text).declarations)
.filter_map(|d| d.id.map(|id| id.to_string()))
.collect();
for diagnostic in &mut report.diagnostics {
if diagnostic
.object_id
.as_ref()
.is_some_and(|id| !explicit_ids.contains(id))
{
diagnostic.object_id = None;
}
}
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
report.identities_to_stabilize = raw
.files
.values()
.map(|text| {
language::analyze(text)
.declarations
.into_iter()
.filter(|d| d.id.is_none())
.count()
})
.sum();
report.migration = compiled.decoded.migration.clone();
let mutable = !matches!(operation, Operation::Check);
if let Operation::RenameAlias { old, new } = operation {
let project = &compiled.decoded.project;
let Some((id, _)) = project
.identities()
.into_iter()
.find(|(_, alias)| *alias == old)
else {
report.fail(
1,
"unknown_alias",
format!("No declaration has alias '{old}'."),
);
return Err(Box::new(report));
};
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
report.fail(1, "semantic_command_rejected", error.to_string());
Box::new(report.clone())
})?;
compiled = source_project::update_bundle_with_identity_map(
&compiled,
&command.project,
&command.identity_map,
LIMITS,
)
.map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
}
if mutable {
for (path, after) in &compiled.bundle.files {
let before = raw.files.get(path).cloned().unwrap_or_default();
if &before != after {
report.changes.push(Change {
path: path.clone(),
diff: unified_diff(path, &before, after),
before,
after: after.clone(),
});
}
}
report.status = "preview".into();
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
}
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
Ok(Prepared {
report,
snapshot,
compiled,
mutable,
})
}
/// One bounded hunk per file: common prefix/suffix plus three context lines.
/// Linear in input size; no quadratic LCS allocation for large modules.
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let a: Vec<_> = before.split_inclusive('\n').collect();
let b: Vec<_> = after.split_inclusive('\n').collect();
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
let suffix = a[prefix..]
.iter()
.rev()
.zip(b[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
let start = prefix.saturating_sub(3);
let a_end = (a.len() - suffix + 3).min(a.len());
let b_end = (b.len() - suffix + 3).min(b.len());
let a_start = if a_end == start { start } else { start + 1 };
let b_start = if b_end == start { start } else { start + 1 };
let mut out = format!(
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
a_end - start,
b_end - start
);
let mut emit = |prefix: char, line: &str| {
out.push(prefix);
out.push_str(line);
if !line.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
for line in &a[start..prefix] {
emit(' ', line);
}
for line in &a[prefix..a.len() - suffix] {
emit('-', line);
}
for line in &b[prefix..b.len() - suffix] {
emit('+', line);
}
for line in &a[a.len() - suffix..a_end] {
emit(' ', line);
}
out
}
pub struct Arguments {
pub entry: PathBuf,
pub operation: Operation,
pub write: bool,
pub json: bool,
}
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
let mut positional = Vec::new();
let (mut write, mut json) = (false, false);
for arg in args {
if arg == "--write" && !write {
write = true;
} else if arg == "--json" && !json {
json = true;
} else if arg.to_string_lossy().starts_with('-') {
return Err(format!(
"Unknown or repeated option: {}",
arg.to_string_lossy()
));
} else {
positional.push(arg);
}
}
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
let operation = match (command, positional.len()) {
("check" | "lint", 2) if !write => Operation::Check,
("stabilize", 2) => Operation::Stabilize,
("rename-alias", 4) => Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
},
_ => return Err(HELP.into()),
};
Ok(Arguments {
entry: PathBuf::from(positional[1]),
operation,
write,
json,
})
}
-102
View File
@@ -1,102 +0,0 @@
use oa_cli::{Report, arguments, prepare};
use std::{
io::{self, Write},
path::Path,
process::ExitCode,
};
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
let mut err = io::stderr().lock();
for d in &report.diagnostics {
let at = d
.location
.as_ref()
.map(|l| {
format!(
"{}:{}:{}",
l.path,
l.line.unwrap_or(0),
l.column.unwrap_or(0)
)
})
.unwrap_or_else(|| report.entry.clone());
writeln!(
err,
"{at}: {}[{}] ({}): {}",
d.severity, d.code, d.coordinate_space, d.message
)?;
}
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
if let Some(migration) = &report.migration {
writeln!(
err,
"Source migration: schema {} → {} (separate from identity stabilization)",
migration.from_schema, migration.to_schema
)?;
}
for note in &report.notes {
writeln!(err, "{note}")?;
}
if show_diff {
let mut out = io::stdout().lock();
for change in &report.changes {
write!(out, "{}", change.diff)?;
}
out.flush()?;
}
err.flush()
}
fn run() -> io::Result<u8> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
return Ok(0);
}
if args.len() == 1 && args[0] == "--version" {
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
return Ok(0);
}
let json = args.iter().any(|a| a == "--json");
let mut displayed = false;
let report = match arguments(&args) {
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
Ok(args) => match prepare(&args.entry, args.operation) {
Err(report) => *report,
Ok(prepared) => {
if args.write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
}
prepared.write()
} else {
prepared.into_report()
}
}
},
};
if json {
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
writeln!(io::stdout())?;
} else {
human_report(&report, !displayed)?;
}
Ok(report.exit_code)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
let _ = writeln!(io::stderr(), "Output failed: {error}");
ExitCode::from(3)
}
}
}
-425
View File
@@ -1,425 +0,0 @@
use oa_cli::{LIMITS, Operation, Prepared, prepare};
use oa_core::{Id, Project, source_files, source_project};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn entry(&self) -> PathBuf {
self.0.join("project.arcadia")
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.0.join(name)).unwrap()
}
fn prepare(&self, op: Operation) -> Prepared {
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
}
fn model(&self) -> Project {
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
.unwrap()
.decoded
.project
}
fn cli(&self, args: &[&str]) -> (i32, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
command
.arg(args[0])
.arg(self.entry())
.args(&args[1..])
.arg("--json");
let output = command.output().unwrap();
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
(
output.status.code().unwrap(),
serde_json::from_slice(&output.stdout).unwrap(),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
#[test]
fn complete_alarm_example_preview_then_write_and_fixed_point() {
let fixture = Fixture::new();
let source = include_str!(
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
);
fixture.put("project.arcadia", source);
let (code, check) = fixture.cli(&["check"]);
assert_eq!(code, 0);
assert_eq!(check["diagnostics"], serde_json::json!([]));
let (code, preview) = fixture.cli(&["stabilize"]);
assert_eq!(code, 0);
assert_eq!(preview["identities_to_stabilize"], 40);
assert_eq!(preview["status"], "preview");
assert_eq!(fixture.read("project.arcadia"), source);
assert!(
preview["changes"][0]["diff"]
.as_str()
.unwrap()
.contains("+project alarm_response")
);
let (code, written) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
let saved = fixture.read("project.arcadia");
assert_eq!(written["changes"][0]["after"], saved);
let project = fixture.model();
assert_eq!(project.identities().len(), 40);
let mut stripped = saved.clone();
for (id, _) in project.identities() {
stripped = stripped.replace(&format!(" id \"{id}\""), "");
}
assert_eq!(stripped, source, "only identity clauses may be inserted");
let (code, again) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
assert_eq!(again["changes"], serde_json::json!([]));
assert_eq!(again["status"], "unchanged");
assert_eq!(fixture.model(), project);
assert_eq!(fixture.read("project.arcadia"), saved);
}
#[test]
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let candidate = fixture.prepare(Operation::Stabilize);
let expected = candidate.report().changes[0].after.clone();
assert_eq!(candidate.report().identities_to_stabilize, 2);
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
assert_eq!(candidate.write().exit_code, 0);
assert_eq!(fixture.read("project.arcadia"), expected);
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
assert!(!expected.contains("description"));
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
let report = fixture.prepare(Operation::Stabilize).write();
assert_eq!(report.status, "unchanged");
assert!(!report.written);
assert_eq!(
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
metadata
);
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
}
#[test]
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
fixture.put(
"participants.arcadia",
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
);
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
.into_iter()
.map(|p| (p, fixture.read(p)))
.collect();
let team = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "team")
.unwrap()
.0;
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "rescue_team".into(),
});
assert_eq!(candidate.report().changes.len(), 2);
assert_eq!(fixture.model(), before, "preview cannot mutate");
assert_eq!(candidate.write().exit_code, 0);
let mut expected = before;
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
assert_eq!(fixture.model(), expected);
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
assert_eq!(
fixture.read("participants.arcadia"),
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
);
assert_eq!(
fixture.read("work.arcadia"),
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
);
}
#[test]
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
let fixture = Fixture::new();
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
fixture.put("project.arcadia", raw);
for command in [
vec!["check"],
vec!["stabilize", "--write"],
vec!["rename-alias", "a", "b", "--write"],
] {
let (code, report) = fixture.cli(&command);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
let diagnostics = report["diagnostics"].as_array().unwrap();
assert!(!diagnostics.is_empty());
for d in diagnostics {
assert_eq!(d["coordinate_space"], "input");
let at = &d["location"];
let start = at["start"].as_u64().unwrap() as usize;
assert!(raw.is_char_boundary(start));
assert_eq!(
at["line"].as_u64().unwrap() as usize,
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
);
let line = raw[..start]
.rsplit_once('\n')
.map_or(&raw[..start], |(_, tail)| tail);
assert_eq!(
at["column"].as_u64().unwrap() as usize,
line.chars().count() + 1
);
}
}
}
#[test]
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
let fixture = Fixture::new();
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
fixture.put("project.arcadia", raw);
let (code, result) = fixture.cli(&["lint"]);
assert_eq!(code, 0, "engineering warnings do not reject admission");
let warning = result["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|d| d["code"] == "work_without_behavior_context")
.unwrap();
assert_eq!(warning["severity"], "warning");
assert_eq!(
warning["location"]["start"],
raw.find("oa_activity").unwrap()
);
assert_eq!(warning["location"]["line"], 3);
assert_eq!(warning["location"]["column"], 5);
assert!(warning["object_id"].is_null());
assert_eq!(fixture.read("project.arcadia"), raw);
}
#[test]
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let pending = fixture.prepare(Operation::Stabilize);
let external = format!("{SIMPLE}// editor changed this\n");
fixture.put("project.arcadia", &external);
let result = pending.write();
assert_eq!(result.exit_code, 3);
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
assert_eq!(fixture.read("project.arcadia"), external);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
assert!(noop.report().changes.is_empty());
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
fixture.put("project.arcadia", &latest);
assert_eq!(noop.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), latest);
}
#[test]
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
let source = fixture.read("project.arcadia");
// An opaque incomplete journal must never be interpreted/recovered by CLI.
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
fs::create_dir(&journal).unwrap();
fs::write(journal.join("sentinel"), b"retain").unwrap();
for op in [Operation::Check, Operation::Stabilize] {
let report = prepare(&fixture.entry(), op).err().unwrap();
assert_eq!(report.exit_code, 3);
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
}
assert_eq!(
noop.write().exit_code,
3,
"saver must recheck after preview"
);
assert_eq!(fixture.read("project.arcadia"), source);
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
}
#[test]
fn rejected_alias_and_same_alias_preserve_source() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let source = fixture.read("project.arcadia");
for (old, new) in [
("team", "rescue"),
("team", "invalid alias"),
("absent", "valid"),
] {
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
assert_eq!(code, 1);
assert_eq!(result["status"], "rejected");
assert_eq!(fixture.read("project.arcadia"), source);
}
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
assert_eq!(code, 0);
assert_eq!(result["status"], "unchanged");
assert_eq!(fixture.read("project.arcadia"), source);
}
#[test]
fn rename_shows_missing_identity_and_separate_source_migration() {
let fixture = Fixture::new();
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
fixture.put("project.arcadia", old);
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "crew".into(),
});
let report = candidate.report();
assert_eq!(report.identities_to_stabilize, 2);
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
assert!(report.changes[0].diff.contains("-arcadia 5;"));
assert!(report.changes[0].diff.contains("+arcadia 7;"));
assert!(report.changes[0].after.contains("oa_entity crew"));
assert_eq!(fixture.read("project.arcadia"), old);
assert_eq!(candidate.write().exit_code, 0);
}
#[test]
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
.current_dir(&fixture.0)
.args(["check", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(error["diagnostics"][0]["code"], "usage");
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
let (code, _) = fixture.cli(&["check", "--write"]);
assert_eq!(code, 2);
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
}
#[test]
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
let fixture = Fixture::new();
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
fixture.put("project.arcadia", entry);
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 3, "a disk dependency could not be read");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "missing_include")
);
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1, "invalid encoding is invalid raw source");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "source_encoding")
);
assert_eq!(fixture.read("project.arcadia"), entry);
assert_eq!(
fs::read(fixture.0.join("part.arcadia")).unwrap(),
[0xff, 0xfe]
);
for raw in [
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
"arcadia 2; project p \"P\" {}",
] {
fixture.put("project.arcadia", raw);
let (code, _) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
}
}
#[test]
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
fixture.put(
"detached.arcadia",
"invalid detached draft must stay exact 雨",
);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let relation = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "responsible")
.unwrap()
.0;
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
assert_eq!(code, 0);
let mut expected = before;
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
assert_eq!(fixture.model(), expected);
assert!(
fixture
.read("project.arcadia")
.contains("@ // keep edge comment\nteam")
);
let captured = fixture.prepare(Operation::RenameAlias {
old: "p".into(),
new: "rescue".into(),
});
assert!(
captured
.report()
.changes
.iter()
.all(|c| c.path != "part.arcadia")
);
let root_before = fixture.read("project.arcadia");
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
assert_eq!(captured.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), root_before);
assert_eq!(
fixture.read("part.arcadia"),
"// external unchanged-dependency edit\n"
);
assert_eq!(
fixture.read("detached.arcadia"),
"invalid detached draft must stay exact 雨"
);
}
-3
View File
@@ -12,9 +12,6 @@ uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
same-file = "1"
num-bigint = "0.5.1"
num-integer = "0.1.47"
num-traits = "0.2.19"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
-103
View File
@@ -1,103 +0,0 @@
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
//!
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
//! expression/call/property/list/target visits and retain the same logical walk on
//! cache hits. Stored AST size is not a dynamic execution limit.
//!
//! Approximate engineering functions/irrational unit scales require a subsequent
//! explicit numeric profile; rationals do not implement degree/radian conversion
//! or transcendental mathematics.
mod dimension;
mod exact;
mod meter;
mod quantity;
pub use dimension::{Dimension, DimensionAxis};
pub use exact::{Exact, ExactWire};
pub use meter::{
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
WorkSite, WorkUsage,
};
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Profile identity includes the accounting rules, not crate-internal timing.
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
pub const MAX_COMPONENT_BITS: u64 = 4096;
/// Cross products require 8192 bits; their sum can require one additional bit.
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
pub const MAX_COMPONENT_DIGITS: usize = 1234;
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
pub const MAX_INTEGER_POWER: i8 = 16;
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumberComponent {
Numerator,
Denominator,
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericLimit {
#[error("request work exhausted: {0}")]
Work(WorkExhaustion),
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
Bits {
component: NumberComponent,
bits: u64,
limit: u64,
stage: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericError {
#[error("numeric limit: {0}")]
Limit(NumericLimit),
#[error("invalid numeric literal")]
InvalidLiteral,
#[error("numeric literal exceeds its digit or byte limit")]
LiteralTooLong,
#[error("decimal exponent is outside -128 through 128")]
DecimalExponent,
#[error("wire rational is not in canonical reduced form")]
NonCanonicalWire,
#[error("rational denominator is zero")]
ZeroDenominator,
#[error("division by zero")]
DivisionByZero,
#[error("zero to the power zero is undefined in this profile")]
ZeroPowerZero,
#[error("integer power is outside -16 through 16")]
IntegerPower,
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
DimensionExponent { axis: DimensionAxis, value: i32 },
#[error("unit scale must be strictly positive")]
UnitScale,
#[error("a linear unit must have zero offset")]
LinearOffset,
#[error("an affine point must have a distinct linear difference kind")]
DifferenceKind,
#[error("numeric kinds or dimensions are incompatible")]
IncompatibleTypes,
#[error("operation is not defined for affine points")]
AffineOperation,
#[error("AsKind requires an anonymous matching dimension and a linear target")]
KindAssertion,
#[error("request work limits exceed the provisional reference ceilings")]
InvalidWorkLimits,
}
impl NumericError {
pub fn is_terminal_work_exhaustion(&self) -> bool {
matches!(self, Self::Limit(NumericLimit::Work(_)))
}
}
#[cfg(test)]
mod tests;
@@ -1,107 +0,0 @@
use super::NumericError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionAxis {
Length,
Mass,
Time,
ElectricCurrent,
Temperature,
Amount,
LuminousIntensity,
}
impl DimensionAxis {
pub const ALL: [Self; 7] = [
Self::Length,
Self::Mass,
Self::Time,
Self::ElectricCurrent,
Self::Temperature,
Self::Amount,
Self::LuminousIntensity,
];
}
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
/// This is a dimension, never a nominal quantity-kind compatibility proof.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
pub struct Dimension([i16; 7]);
impl Dimension {
pub const ZERO: Self = Self([0; 7]);
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
for (index, value) in exponents.iter().copied().enumerate() {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value: i32::from(value),
});
}
}
Ok(Self(exponents))
}
pub fn exponents(self) -> [i16; 7] {
self.0
}
pub fn is_dimensionless(self) -> bool {
self == Self::ZERO
}
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, 1)
}
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, -1)
}
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
}
Ok(Self(output))
}
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] =
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
}
Ok(Self(output))
}
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value,
});
}
Ok(value as i16)
}
}
impl TryFrom<[i16; 7]> for Dimension {
type Error = NumericError;
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Dimension> for [i16; 7] {
fn from(value: Dimension) -> Self {
value.0
}
}
@@ -1,469 +0,0 @@
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
use super::{
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
NumericOperation, WorkLimits, WorkMeter,
};
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{One, Signed, Zero};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
/// The interchange representation deliberately does not use num-bigint's serde
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
/// Its derived decoder allocates String fields before numeric validation; a
/// transport/document adapter must bound the encoded envelope and decoded text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExactWire {
pub numerator: String,
pub denominator: String,
}
/// Reduced rational with positive denominator and <=4096-bit components.
///
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
/// of already normalized bounded values, not an expression comparison API.
/// Standalone serde admission bounds the subsequent BigInt validation, not the
/// preceding String decoding. Document adapters must bound encoded/decoded input
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ExactWire", into = "ExactWire")]
pub struct Exact {
numerator: BigInt,
denominator: BigInt,
}
impl Exact {
pub fn from_integer(value: i64) -> Self {
Self {
numerator: BigInt::from(value),
denominator: BigInt::one(),
}
}
pub fn is_zero(&self) -> bool {
self.numerator.is_zero()
}
pub fn is_positive(&self) -> bool {
self.numerator.is_positive()
}
pub fn component_bits(&self) -> (u64, u64) {
(self.numerator.bits(), self.denominator.bits())
}
pub fn to_wire(&self) -> ExactWire {
ExactWire {
numerator: self.numerator.to_string(),
denominator: self.denominator.to_string(),
}
}
/// Canonical *literal* spelling, not a generic expression formatter.
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
/// belongs to the future expression parser's UnaryNegate node instead.
pub fn canonical_literal(&self) -> String {
if self.denominator.is_one() && !self.numerator.is_negative() {
self.numerator.to_string()
} else {
format!("ratio({}, {})", self.numerator, self.denominator)
}
}
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let n_digits = wire_digits(&wire.numerator, true)?;
let d_digits = wire_digits(&wire.denominator, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
meter.numeric(
NumericOperation::ParseWire,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let numerator = parse_integer(&wire.numerator)?;
let denominator = parse_integer(&wire.denominator)?;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if !numerator.gcd(&denominator).is_one() {
return Err(NumericError::NonCanonicalWire);
}
Ok(Self {
numerator,
denominator,
})
}
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
/// It does not parse expressions, unary operators, units, calls or names.
/// Source ratios/decimals may reduce; wire values must already be canonical.
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if source.len() > MAX_LITERAL_BYTES {
return Err(NumericError::LiteralTooLong);
}
let source = trim_ascii(source);
if let Some(body) = source.strip_prefix("ratio(") {
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
}
Self::parse_decimal(source, meter)
}
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let n_digits = source_integer_digits(n, true)?;
let d_digits = source_integer_digits(d, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
}
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let mut parts = source.split(['e', 'E']);
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
let exponent = match parts.next() {
Some(text) => parse_exponent(text)?,
None => 0,
};
if parts.next().is_some() {
return Err(NumericError::InvalidLiteral);
}
let (whole, fraction) = match mantissa.split_once('.') {
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
Some(_) => return Err(NumericError::InvalidLiteral),
None => (mantissa, ""),
};
source_integer_digits(whole, false)?;
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let digit_count = whole.len() + fraction.len();
if digit_count > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
let adjustment = exponent - fraction.len() as i32;
let power = adjustment.unsigned_abs();
let mantissa_bound = decimal_bits(digit_count);
let scale_bound = decimal_bits(power as usize + 1);
let (n_bound, d_bound) = if adjustment >= 0 {
(mantissa_bound + scale_bound, 1)
} else {
(mantissa_bound, scale_bound)
};
Self::check_estimates(n_bound, d_bound)?;
// Fixed logical allowance for constructing 10^power; independent of
// num-bigint's selected multiplication/exponentiation implementation.
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(digit_count, mantissa_bound)
+ power_steps * product_cost(scale_bound, scale_bound)
+ product_cost(mantissa_bound, scale_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let digits = format!("{whole}{fraction}");
let mut numerator = parse_integer(&digits)?;
let scale = BigInt::from(10_u8).pow(power);
let denominator = if adjustment >= 0 {
numerator *= scale;
BigInt::one()
} else {
scale
};
Self::normalize_precharged(numerator, denominator)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, false, meter)
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, true, meter)
}
fn add_or_subtract(
&self,
rhs: &Self,
subtract: bool,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
let left_bound = a + d;
let right_bound = c + b;
let n_bound = left_bound.max(right_bound) + 1;
let d_bound = b + d;
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
if subtract {
NumericOperation::Subtract
} else {
NumericOperation::Add
},
product_cost(a, d)
+ product_cost(c, b)
+ sum_cost(left_bound, right_bound)
+ product_cost(b, d)
+ normalization_cost(n_bound, d_bound),
)?;
let left = &self.numerator * &rhs.denominator;
let right = &rhs.numerator * &self.denominator;
let numerator = if subtract { left - right } else { left + right };
let denominator = &self.denominator * &rhs.denominator;
Self::normalize_precharged(numerator, denominator)
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + c, b + d)?;
meter.numeric(
NumericOperation::Multiply,
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
)?;
Self::normalize_precharged(
&self.numerator * &rhs.numerator,
&self.denominator * &rhs.denominator,
)
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + d, b + c)?;
meter.numeric(
NumericOperation::Divide,
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
)?;
if rhs.is_zero() {
return Err(NumericError::DivisionByZero);
}
Self::normalize_precharged(
&self.numerator * &rhs.denominator,
&self.denominator * &rhs.numerator,
)
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates((a + d).max(c + b), 1)?;
meter.numeric(
NumericOperation::Compare,
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
)?;
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
Ok(Self {
numerator: -&self.numerator,
denominator: self.denominator.clone(),
})
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
Ok(Self {
numerator: self.numerator.abs(),
denominator: self.denominator.clone(),
})
}
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
if self.is_zero() {
return Err(NumericError::DivisionByZero);
}
Ok(Self {
numerator: if self.numerator.is_negative() {
-&self.denominator
} else {
self.denominator.clone()
},
denominator: self.numerator.abs(),
})
}
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
/// Each reduced stage obeys the same component bound; no large power is
/// allocated and subsequently discarded after discovering it is oversized.
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Power, self.copy_cost())?;
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_zero() && exponent == 0 {
return Err(NumericError::ZeroPowerZero);
}
let factor = if exponent < 0 {
self.reciprocal(meter)?
} else {
self.clone()
};
let mut value = Self::from_integer(1);
for _ in 0..exponent.unsigned_abs() {
value = value.multiply(&factor, meter)?;
}
Ok(value)
}
pub(super) fn copy_cost(&self) -> u64 {
let (n, d) = self.component_bits();
1 + words(n) + words(d)
}
fn normalize_precharged(
mut numerator: BigInt,
mut denominator: BigInt,
) -> Result<Self, NumericError> {
Self::check_bits(
&numerator,
&denominator,
MAX_INTERMEDIATE_BITS,
"intermediate",
)?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if denominator.is_negative() {
numerator = -numerator;
denominator = -denominator;
}
let gcd = numerator.gcd(&denominator);
numerator /= &gcd;
denominator /= gcd;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
Ok(Self {
numerator,
denominator,
})
}
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
Self::check_bit_count(
n,
NumberComponent::Numerator,
MAX_INTERMEDIATE_BITS,
"predicted",
)?;
Self::check_bit_count(
d,
NumberComponent::Denominator,
MAX_INTERMEDIATE_BITS,
"predicted",
)
}
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
}
fn check_bit_count(
bits: u64,
component: NumberComponent,
limit: u64,
stage: &str,
) -> Result<(), NumericError> {
if bits > limit {
return Err(NumericError::Limit(NumericLimit::Bits {
component,
bits,
limit,
stage: stage.to_owned(),
}));
}
Ok(())
}
}
impl TryFrom<ExactWire> for Exact {
type Error = NumericError;
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
// BigInt validation is bounded after ExactWire strings have decoded.
// The adapter owns envelope/text/aggregate admission bounds and must not
// reset evaluation meters.
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
}
}
impl From<Exact> for ExactWire {
fn from(value: Exact) -> Self {
value.to_wire()
}
}
fn trim_ascii(text: &str) -> &str {
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
}
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = if signed {
text.strip_prefix('-').unwrap_or(text)
} else {
text
};
if digits.len() > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
Ok(digits)
}
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = source_integer_digits(text, signed).map_err(|error| match error {
NumericError::LiteralTooLong => error,
_ => NumericError::NonCanonicalWire,
})?;
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
return Err(NumericError::NonCanonicalWire);
}
Ok(digits)
}
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
}
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
(true, digits)
} else {
(false, text.strip_prefix('+').unwrap_or(text))
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let mut value = 0_i32;
for digit in digits.bytes() {
value = value * 10 + i32::from(digit - b'0');
if value > MAX_DECIMAL_EXPONENT {
return Err(NumericError::DecimalExponent);
}
}
Ok(if negative { -value } else { value })
}
fn decimal_bits(digits: usize) -> u64 {
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
(digits as u64 * 3322).div_ceil(1000).max(1)
}
@@ -1,32 +0,0 @@
[
{
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"expression": {
"Number": {
"denominator": "1",
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
},
"name": "ten_to_300"
},
{
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
"expression": {
"Number": {
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
"numerator": "1"
}
},
"name": "one_over_two_to_4095"
},
{
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
"expression": {
"Number": {
"denominator": "1",
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
}
},
"name": "negative_4096_bit_bound"
}
]
@@ -1,240 +0,0 @@
use super::{NumericError, NumericLimit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Every event is charged once per dynamic occurrence, before entering it.
///
/// A Call expression therefore charges Expression and CalculationEntry; a
/// Property read charges Expression and PropertyBinding. List element visits
/// are additional to evaluation of their expressions. Only executed branches
/// are charged. Targets are sorted by category (properties, then checks), then
/// stable UUID bytes, by the integrating evaluator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvaluationStep {
Target,
Expression,
CalculationEntry,
PropertyBinding,
ListElement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumericOperation {
ParseWire,
ParseLiteral,
Add,
Subtract,
Multiply,
Divide,
Compare,
Negate,
Absolute,
Reciprocal,
Power,
KindAssertion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkResource {
DynamicEvents,
NumericUnits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkSite {
Evaluation(EvaluationStep),
Numeric(NumericOperation),
}
/// Failed reservations consume no partial charge. The first failure is latched.
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
pub struct WorkExhaustion {
pub resource: WorkResource,
pub used: u64,
pub requested: u64,
pub limit: u64,
pub site: WorkSite,
}
/// Provisional calibration ceilings, not measured execution-time guarantees.
///
/// Effective limits, profile identity, capture, targets and context must all be
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
pub struct WorkLimits {
dynamic_events: u64,
numeric_units: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkLimitsWire {
dynamic_events: u64,
numeric_units: u64,
}
impl WorkLimits {
pub const REFERENCE: Self = Self {
dynamic_events: 1_000_000,
numeric_units: 50_000_000,
};
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
if dynamic_events > Self::REFERENCE.dynamic_events
|| numeric_units > Self::REFERENCE.numeric_units
{
return Err(NumericError::InvalidWorkLimits);
}
Ok(Self {
dynamic_events,
numeric_units,
})
}
pub fn dynamic_events(self) -> u64 {
self.dynamic_events
}
pub fn numeric_units(self) -> u64 {
self.numeric_units
}
}
impl TryFrom<WorkLimitsWire> for WorkLimits {
type Error = NumericError;
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
Self::new(value.dynamic_events, value.numeric_units)
}
}
impl From<WorkLimits> for WorkLimitsWire {
fn from(value: WorkLimits) -> Self {
Self {
dynamic_events: value.dynamic_events,
numeric_units: value.numeric_units,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkUsage {
pub dynamic_events: u64,
pub numeric_units: u64,
}
/// Intentionally neither Clone nor Default: pass the same mutable meter through
/// every target, numeric operation and expanded call in one evaluation request.
///
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
/// mark already collected causes as incomplete. Replaying an aggregate cache
/// cost is insufficient if it changes the exact exhaustion location; initially
/// retain logical traversal even when numerical values come from a cache.
#[derive(Debug)]
pub struct WorkMeter {
limits: WorkLimits,
usage: WorkUsage,
exhausted: Option<WorkExhaustion>,
}
impl WorkMeter {
pub fn new(limits: WorkLimits) -> Self {
Self {
limits,
usage: WorkUsage::default(),
exhausted: None,
}
}
pub fn limits(&self) -> WorkLimits {
self.limits
}
pub fn usage(&self) -> WorkUsage {
self.usage
}
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
self.exhausted.as_ref()
}
pub fn ensure_active(&self) -> Result<(), NumericError> {
match &self.exhausted {
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
None => Ok(()),
}
}
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
}
pub(super) fn numeric(
&mut self,
operation: NumericOperation,
units: u64,
) -> Result<(), NumericError> {
self.reserve(
WorkResource::NumericUnits,
units,
WorkSite::Numeric(operation),
)
}
fn reserve(
&mut self,
resource: WorkResource,
requested: u64,
site: WorkSite,
) -> Result<(), NumericError> {
self.ensure_active()?;
let (used, limit) = match resource {
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
};
// Subtraction is safe: only successful reservations update usage.
if requested > limit - used {
let failure = WorkExhaustion {
resource,
used,
requested,
limit,
site,
};
self.exhausted = Some(failure.clone());
return Err(NumericError::Limit(NumericLimit::Work(failure)));
}
match resource {
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
WorkResource::NumericUnits => self.usage.numeric_units += requested,
}
Ok(())
}
}
// Logical 64-bit words are platform independent. These are deterministic
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
pub(super) fn words(bits: u64) -> u64 {
bits.max(1).div_ceil(64)
}
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits) * words(b_bits)
}
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits.max(b_bits))
}
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
let width = words(n_bits.max(d_bits));
// 64*w² for GCD plus 2*w² for the two quotient operations.
1 + 66 * width * width
}
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
1 + digits as u64 * words(estimated_bits)
}
@@ -1,378 +0,0 @@
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
use std::cmp::Ordering;
/// K is supplied by the common resolver's verified nominal compatibility
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
/// Exact definition references and pins remain the caller's provenance.
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
/// comparing an unbounded rooted contract graph is resolver admission work and
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
/// not enforce this resource contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedKind<K> {
key: K,
dimension: Dimension,
difference: Option<K>,
}
impl<K> ResolvedKind<K> {
pub fn linear(key: K, dimension: Dimension) -> Self {
Self {
key,
dimension,
difference: None,
}
}
pub fn key(&self) -> &K {
&self.key
}
pub fn dimension(&self) -> Dimension {
self.dimension
}
pub fn is_affine(&self) -> bool {
self.difference.is_some()
}
}
impl<K: Clone + Eq> ResolvedKind<K> {
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
if difference.is_affine() || key == difference.key {
return Err(NumericError::DifferenceKind);
}
Ok(Self {
key,
dimension: difference.dimension,
difference: Some(difference.key.clone()),
})
}
pub fn difference_kind(&self) -> Option<Self> {
self.difference
.as_ref()
.map(|key| Self::linear(key.clone(), self.dimension))
}
}
/// Canonical coordinate = magnitude * scale + offset.
/// Unit symbols and display preferences are outside this numeric representation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit<K> {
kind: ResolvedKind<K>,
scale: Exact,
offset: Exact,
}
impl<K: Clone + Eq> Unit<K> {
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
if !scale.is_positive() {
return Err(NumericError::UnitScale);
}
if !kind.is_affine() && !offset.is_zero() {
return Err(NumericError::LinearOffset);
}
Ok(Self {
kind,
scale,
offset,
})
}
pub fn kind(&self) -> &ResolvedKind<K> {
&self.kind
}
pub fn scale(&self) -> &Exact {
&self.scale
}
pub fn offset(&self) -> &Exact {
&self.offset
}
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
/// A mathematically cancelling wider intermediate still reports NumericLimit;
/// replacing this path with a fused operation requires a new work profile.
pub fn to_canonical(
&self,
magnitude: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
magnitude
.multiply(&self.scale, meter)?
.add(&self.offset, meter)
}
/// Fixed subtract-then-divide path; never rounds a coordinate.
pub fn from_canonical(
&self,
coordinate: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
coordinate
.subtract(&self.offset, meter)?
.divide(&self.scale, meter)
}
pub fn convert(
&self,
magnitude: &Exact,
target: &Self,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if self.kind != target.kind {
return Err(NumericError::IncompatibleTypes);
}
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
}
}
/// Internal numerical types. Boolean/list/check semantics belong to the common
/// expression evaluator. Anonymous zero dimension normalizes to Number.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NumericType<K> {
Number,
Named(ResolvedKind<K>),
Anonymous(Dimension),
}
impl<K> NumericType<K> {
pub fn dimension(&self) -> Dimension {
match self {
Self::Number => Dimension::ZERO,
Self::Named(kind) => kind.dimension,
Self::Anonymous(dimension) => *dimension,
}
}
pub fn is_affine(&self) -> bool {
matches!(self, Self::Named(kind) if kind.is_affine())
}
fn anonymous(dimension: Dimension) -> Self {
if dimension.is_dimensionless() {
Self::Number
} else {
Self::Anonymous(dimension)
}
}
fn normalized(self) -> Self {
match self {
Self::Anonymous(dimension) => Self::anonymous(dimension),
other => other,
}
}
}
/// A canonical rational coordinate with an internal numerical type.
///
/// Derived Eq compares stored representation. Formal equality/ordering must call
/// compare(), which rejects mismatched nominal kinds instead of treating them
/// as a false predicate. Public property output must still check its declared
/// type and anchor the result to that declaration's exact definition reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumericValue<K> {
value_type: NumericType<K>,
coordinate: Exact,
}
impl<K: Clone + Eq> NumericValue<K> {
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
Self {
value_type: value_type.normalized(),
coordinate,
}
}
pub fn number(coordinate: Exact) -> Self {
Self::new(NumericType::Number, coordinate)
}
pub fn from_unit(
magnitude: &Exact,
unit: &Unit<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
Ok(Self::new(
NumericType::Named(unit.kind.clone()),
unit.to_canonical(magnitude, meter)?,
))
}
pub fn value_type(&self) -> &NumericType<K> {
&self.value_type
}
pub fn coordinate(&self) -> &Exact {
&self.coordinate
}
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
return Err(NumericError::IncompatibleTypes);
}
unit.from_canonical(&self.coordinate, meter)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.additive_type(rhs, false)?;
Ok(Self::new(
output,
self.coordinate.add(&rhs.coordinate, meter)?,
))
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.additive_type(rhs, true)?;
Ok(Self::new(
output,
self.coordinate.subtract(&rhs.coordinate, meter)?,
))
}
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
use NumericType::{Anonymous, Named, Number};
match (&self.value_type, &rhs.value_type) {
(Number, Number) => Ok(Number),
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
(None, None) if left == right => Ok(Named(left.clone())),
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
ResolvedKind::linear(difference.clone(), left.dimension),
)),
(Some(difference), None)
if difference == &right.key && left.dimension == right.dimension =>
{
Ok(Named(left.clone()))
}
(None, Some(difference))
if !subtract
&& difference == &left.key
&& left.dimension == right.dimension =>
{
Ok(Named(right.clone()))
}
_ => Err(NumericError::IncompatibleTypes),
},
_ => Err(NumericError::IncompatibleTypes),
}
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.multiplicative_type(rhs, false)?;
Ok(Self::new(
output,
self.coordinate.multiply(&rhs.coordinate, meter)?,
))
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.multiplicative_type(rhs, true)?;
Ok(Self::new(
output,
self.coordinate.divide(&rhs.coordinate, meter)?,
))
}
fn multiplicative_type(
&self,
rhs: &Self,
divide: bool,
) -> Result<NumericType<K>, NumericError> {
use NumericType::Number;
if self.value_type.is_affine() || rhs.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
match (&self.value_type, &rhs.value_type) {
(Number, Number) => Ok(Number),
(_, Number) => Ok(self.value_type.clone()),
(Number, _) if !divide => Ok(rhs.value_type.clone()),
_ => {
let left = self.value_type.dimension();
let right = rhs.value_type.dimension();
let dimension = if divide {
left.checked_quotient(right)?
} else {
left.checked_product(right)?
};
Ok(NumericType::anonymous(dimension))
}
}
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if self.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(Self::new(
self.value_type.clone(),
self.coordinate.negated(meter)?,
))
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if self.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(Self::new(
self.value_type.clone(),
self.coordinate.absolute(meter)?,
))
}
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
let output = match exponent {
0 => NumericType::Number,
1 => self.value_type.clone(),
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
};
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
meter.ensure_active()?;
if self.value_type != rhs.value_type {
return Err(NumericError::IncompatibleTypes);
}
self.coordinate.compare(&rhs.coordinate, meter)
}
pub fn as_kind(
&self,
target: &ResolvedKind<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
if target.is_affine()
|| matches!(self.value_type, NumericType::Named(_))
|| self.value_type.dimension() != target.dimension
{
return Err(NumericError::KindAssertion);
}
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
Ok(Self::new(
NumericType::Named(target.clone()),
self.coordinate.clone(),
))
}
}
@@ -1,804 +0,0 @@
use super::*;
use num_bigint::BigInt;
use num_traits::One;
use std::cmp::Ordering;
fn meter() -> WorkMeter {
WorkMeter::new(WorkLimits::REFERENCE)
}
fn exact(n: impl ToString, d: impl ToString) -> Exact {
Exact::from_wire(
&ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
},
&mut meter(),
)
.unwrap()
}
fn source(text: &str) -> Exact {
Exact::parse_literal(text, &mut meter()).unwrap()
}
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
assert_eq!(
value.to_wire(),
ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
}
);
}
// Independent bounded i128 oracle. It does not call the production Exact/GCD
// implementation when deriving expected arithmetic or ordering.
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
assert_ne!(d, 0);
if d < 0 {
n = -n;
d = -d;
}
let (mut a, mut b) = (n.abs(), d);
while b != 0 {
(a, b) = (b, a % b);
}
(n / a, d / a)
}
#[test]
fn arithmetic_matches_independent_small_integer_oracle() {
for a in [-5_i128, -2, 0, 1, 4] {
for b in [1_i128, 2, 3, 7] {
for c in [-5_i128, -2, 0, 1, 4] {
for d in [1_i128, 2, 3, 7] {
let (an, ad) = small_fraction(a, b);
let (bn, bd) = small_fraction(c, d);
let left = exact(an, ad);
let right = exact(bn, bd);
let mut work = meter();
let (n, den) = small_fraction(a * d + c * b, b * d);
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * d - c * b, b * d);
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * c, b * d);
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
if c != 0 {
let (n, den) = small_fraction(a * d, b * c);
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
} else {
assert_eq!(
left.divide(&right, &mut work),
Err(NumericError::DivisionByZero)
);
}
assert_eq!(
left.compare(&right, &mut work).unwrap(),
(a * d).cmp(&(c * b))
);
}
}
}
}
}
#[test]
fn decimals_are_exact_and_source_ratios_reduce() {
assert_fraction(
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
3,
10,
);
assert_fraction(&source("0.50"), 1, 2);
assert_fraction(&source("12.50e-2"), 1, 8);
assert_fraction(&source("1e+3"), 1000, 1);
assert_fraction(&source("0001.2500"), 5, 4);
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
assert_eq!(source("42").canonical_literal(), "42");
}
#[test]
fn powers_and_signs_have_explicit_domains() {
let x = exact(-2, 3);
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
let zero = Exact::from_integer(0);
assert_eq!(
zero.power(0, &mut meter()),
Err(NumericError::ZeroPowerZero)
);
assert_eq!(
zero.power(-1, &mut meter()),
Err(NumericError::DivisionByZero)
);
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
assert_eq!(
x.power(i8::MIN, &mut meter()),
Err(NumericError::IntegerPower)
);
}
#[test]
fn canonical_wire_rejects_both_component_noncanonical_forms() {
for (n, d) in [
("+1", "1"),
("01", "1"),
("-0", "1"),
("1", "+1"),
("1", "-1"),
("1", "01"),
("2", "4"),
("0", "3"),
("", "1"),
("1", ""),
("1.0", "1"),
("1", "1.0"),
("1e1", "1"),
("1", "1e1"),
(" 1", "1"),
("1", "1 "),
] {
assert!(
Exact::from_wire(
&ExactWire {
numerator: n.into(),
denominator: d.into()
},
&mut meter(),
)
.is_err(),
"accepted {n}/{d}"
);
}
assert_eq!(
Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "0".into()
},
&mut meter()
),
Err(NumericError::ZeroDenominator)
);
assert_fraction(&exact(0, 1), 0, 1);
assert_fraction(&exact(-1, 2), -1, 2);
}
#[test]
fn serde_uses_closed_canonical_string_wire() {
let value = exact(-3, 7);
let encoded = serde_json::to_value(&value).unwrap();
assert_eq!(
encoded,
serde_json::json!({"numerator":"-3","denominator":"7"})
);
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
for invalid in [
r#"{"numerator":1,"denominator":"2"}"#,
r#"{"numerator":"1","denominator":2}"#,
r#"{"numerator":"1","denominator":"2","other":0}"#,
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
r#"{"numerator":"1"}"#,
r#"{"numerator":"2","denominator":"4"}"#,
] {
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
}
}
#[test]
fn literal_reader_does_not_fold_expression_unary_minus() {
assert_eq!(
Exact::parse_literal("-2", &mut meter()),
Err(NumericError::InvalidLiteral)
);
let negative_literal = source("ratio(-2, 1)");
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
// Equality of coordinates does not claim AST equality. The expression
// parser must preserve UnaryNegate independently of its operand literal.
assert_eq!(source("2").canonical_literal(), "2");
}
#[test]
fn literal_syntax_and_decimal_exponent_are_bounded() {
for invalid in [
"",
"-",
"+1",
".5",
"1.",
"1.2.3",
"1e",
"1ee2",
"1e+-2",
"1_000",
"NaN",
"Infinity",
"1/2",
"ratio(1)",
"ratio(1,2,3)",
"ratio(1,-2)",
"ratio(+1,2)",
"ratio(1,2) + 1",
"12",
] {
assert!(
Exact::parse_literal(invalid, &mut meter()).is_err(),
"{invalid}"
);
}
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
assert_eq!(
Exact::parse_literal(invalid, &mut meter()),
Err(NumericError::DecimalExponent)
);
}
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
assert_eq!(
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
assert_eq!(
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
}
#[test]
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
let packets: serde_json::Value =
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
for packet in packets.as_array().unwrap() {
let wire: ExactWire =
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
let spelling = packet["canonical_source"].as_str().unwrap();
assert_eq!(expected.canonical_literal(), spelling);
assert_eq!(
Exact::parse_literal(spelling, &mut meter()).unwrap(),
expected
);
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(
serde_json::from_str::<Exact>(&serialized).unwrap(),
expected
);
assert_eq!(expected.to_wire(), wire);
}
}
#[test]
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
let n = (BigInt::one() << 4096_usize) - 1_u8;
let d = &n - 1_u8;
let value = exact(&n, &d);
let result = value.add(&value, &mut meter()).unwrap();
assert_fraction(&result, &n, &d / 2_u8);
}
#[test]
fn cross_products_cancel_before_reduced_component_limit() {
let n = BigInt::one() << 4095_usize;
let d = &n - 1_u8;
let left = exact(&n, &d);
let right = exact(&d, &n);
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
assert_fraction(
&left
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
.unwrap(),
0,
1,
);
}
#[test]
fn both_reduced_component_overflows_are_reported() {
let n = BigInt::one() << 4095_usize;
let mut work = meter();
let failure = exact(&n, 1)
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
..
})
));
assert!(work.exhaustion().is_none());
let failure = exact(1, &n)
.divide(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Denominator,
bits: 4097,
..
})
));
// Value overflow is a nonterminal supported-operation failure; fuel
// exhaustion, in contrast, terminates the entire request.
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
}
#[test]
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
let oversized = (BigInt::one() << 4096_usize).to_string();
for wire in [
ExactWire {
numerator: oversized.clone(),
denominator: "1".into(),
},
ExactWire {
numerator: "1".into(),
denominator: oversized.clone(),
},
ExactWire {
numerator: oversized.clone(),
denominator: oversized,
},
] {
assert!(matches!(
Exact::from_wire(&wire, &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
));
}
}
#[test]
fn numeric_precharge_precedes_big_integer_result_allocation() {
let n = BigInt::one() << 4095_usize;
let left = exact(&n, 1);
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
let failure = left
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().numeric_units, 0);
assert!(matches!(
work.exhaustion().unwrap().site,
WorkSite::Numeric(NumericOperation::Multiply)
));
// With available fuel the same operation reaches the 4097-bit result check.
assert!(matches!(
left.multiply(&Exact::from_integer(2), &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { .. }))
));
let too_wide_wire = ExactWire {
numerator: (BigInt::one() << 4096_usize).to_string(),
denominator: "1".into(),
};
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
assert!(
Exact::from_wire(&too_wide_wire, &mut parse_work)
.unwrap_err()
.is_terminal_work_exhaustion()
);
}
#[test]
fn provisional_limits_and_effective_limits_round_trip() {
let limits = WorkLimits::new(500, 2000).unwrap();
let encoded = serde_json::to_value(limits).unwrap();
assert_eq!(
encoded,
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
);
assert_eq!(
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
limits
);
assert_eq!(
WorkLimits::new(1_000_001, 0),
Err(NumericError::InvalidWorkLimits)
);
assert_eq!(
WorkLimits::new(0, 50_000_001),
Err(NumericError::InvalidWorkLimits)
);
assert!(
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
.is_err()
);
}
#[test]
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
// One-word addition: three products at 2, one sum at 2,
// normalization at 67 => 75 logical numeric units.
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
assert_eq!(work.usage().numeric_units, 75);
assert!(work.exhaustion().is_none());
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
let at_failure = work.usage();
assert_eq!(
work.exhaustion().unwrap(),
&WorkExhaustion {
resource: WorkResource::NumericUnits,
used: 75,
requested: 3,
limit: 75,
site: WorkSite::Numeric(NumericOperation::Negate),
}
);
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
assert_eq!(
Exact::from_integer(1)
.divide(&Exact::from_integer(0), &mut work)
.unwrap_err(),
first
);
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
first
);
assert_eq!(work.usage(), at_failure);
}
#[test]
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
work.numeric(NumericOperation::Compare, 0).unwrap();
let failure = work
.numeric(NumericOperation::Multiply, u64::MAX)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage(), WorkUsage::default());
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
failure
);
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
assert!(dynamic.visit(EvaluationStep::Target).is_err());
assert_eq!(dynamic.usage(), WorkUsage::default());
}
#[test]
fn multiple_targets_share_one_aggregate_meter() {
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
work.visit(EvaluationStep::Target).unwrap();
work.visit(EvaluationStep::Expression).unwrap();
work.visit(EvaluationStep::Target).unwrap();
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().dynamic_events, 3);
}
// A direct bounded call-tree workload, not an expression interpreter/dialect.
fn expanded_calls(
depth: u8,
x: u64,
work: &mut WorkMeter,
entered: &mut u64,
) -> Result<u64, NumericError> {
work.visit(EvaluationStep::CalculationEntry)?;
*entered += 1;
if depth == 0 {
return Ok(x);
}
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
Ok(left + right)
}
#[test]
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
let mut entered = 0;
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(entered, 10_000);
assert_eq!(work.usage().dynamic_events, 10_000);
assert_eq!(
work.exhaustion().unwrap().site,
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
);
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
let mut entered = 0;
assert_eq!(
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
496
);
assert_eq!(entered, 63);
assert!(bounded.exhaustion().is_none());
}
fn dim(value: [i16; 7]) -> Dimension {
Dimension::new(value).unwrap()
}
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
ResolvedKind::linear(key.to_owned(), dimension)
}
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
}
#[test]
fn dimensions_use_checked_seven_axis_integer_algebra() {
let length = dim([1, 0, 0, 0, 0, 0, 0]);
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
let time = dim([0, 0, 1, 0, 0, 0, 0]);
assert_eq!(
mass.checked_product(length)
.unwrap()
.checked_quotient(time.checked_power(2).unwrap())
.unwrap(),
dim([1, 1, -2, 0, 0, 0, 0])
);
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
assert!(matches!(
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
Err(NumericError::DimensionExponent {
axis: DimensionAxis::Length,
value: 33
})
));
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
assert!(length.checked_power(i8::MIN).is_err());
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
assert_eq!(
serde_json::to_value(time).unwrap(),
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
);
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
}
}
#[test]
fn duration_unit_coordinates_are_exact_and_nominal() {
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
let total = receive.add(&dispatch, &mut meter()).unwrap();
assert_fraction(total.coordinate(), 7, 4);
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
let different = Unit::new(
linear("different-kind", duration.dimension()),
source("1"),
source("0"),
)
.unwrap();
assert_eq!(
ms.convert(&source("750"), &different, &mut meter()),
Err(NumericError::IncompatibleTypes)
);
let inconsistent = Unit::new(
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
source("1"),
source("0"),
)
.unwrap();
assert!(
ms.convert(&source("1"), &inconsistent, &mut meter())
.is_err()
);
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
assert_eq!(
Unit::new(duration, source("1"), source("1")).unwrap_err(),
NumericError::LinearOffset
);
}
#[test]
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
let fahrenheit = Unit::new(
point.clone(),
source("ratio(5,9)"),
source("ratio(45967,180)"),
)
.unwrap();
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
assert_fraction(
&celsius
.convert(&source("0"), &fahrenheit, &mut meter())
.unwrap(),
32,
1,
);
assert_fraction(
&fahrenheit
.convert(&source("70"), &celsius, &mut meter())
.unwrap(),
190,
9,
);
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
let difference = p20.subtract(&p10, &mut meter()).unwrap();
assert_eq!(
difference.value_type(),
&NumericType::Named(interval.clone())
);
assert_fraction(
&difference
.display_in(&kelvin_interval, &mut meter())
.unwrap(),
10,
1,
);
let delta5 = named(5, &interval);
let p25 = p20.add(&delta5, &mut meter()).unwrap();
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
assert!(p20.add(&p10, &mut meter()).is_err());
assert!(delta5.subtract(&p20, &mut meter()).is_err());
assert_eq!(
p20.power(1, &mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(
p20.negated(&mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
}
#[test]
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
// The fixed multiply stage produces 2^4096, so it must fail before offset
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
let large = BigInt::one() << 4095_usize;
let magnitude = exact(&large, 1);
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
let mut work = meter();
for outcome in [
unit.to_canonical(&magnitude, &mut work),
unit.from_canonical(&magnitude, &mut work),
] {
assert!(matches!(
outcome,
Err(NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
limit: MAX_COMPONENT_BITS,
..
}))
));
}
assert!(work.exhaustion().is_none());
let half = exact(&large >> 1_usize, 1);
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
assert_eq!(
unit.from_canonical(&Exact::from_integer(0), &mut work)
.unwrap(),
half
);
}
#[test]
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let a = named(3, &force)
.multiply(&named(4, &length), &mut meter())
.unwrap();
let b = named(2, &force)
.multiply(&named(7, &length), &mut meter())
.unwrap();
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
let sum = a.add(&b, &mut meter()).unwrap();
let result = sum.as_kind(&energy, &mut meter()).unwrap();
assert_fraction(result.coordinate(), 26, 1);
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
assert!(result.add(&sum, &mut meter()).is_err());
assert_eq!(
result.as_kind(&energy, &mut meter()),
Err(NumericError::KindAssertion)
);
}
#[test]
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
let result = named(2, &mass)
.multiply(&named(3, &length), &mut meter())
.unwrap()
.divide(&named(2, &time), &mut meter())
.unwrap()
.as_kind(&momentum, &mut meter())
.unwrap();
assert_fraction(result.coordinate(), 3, 1);
let scalar = NumericValue::number(source("2"));
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
assert_fraction(scaled.coordinate(), 6, 1);
assert_eq!(
named(3, &time)
.divide(&scalar, &mut meter())
.unwrap()
.value_type(),
&NumericType::Named(time.clone())
);
assert_eq!(
scalar
.divide(&named(3, &time), &mut meter())
.unwrap()
.value_type(),
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
);
}
#[test]
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let torque = linear("torque", energy.dimension());
let left = named(7, &energy);
let right = named(7, &torque);
assert!(left.add(&right, &mut meter()).is_err());
assert!(left.compare(&right, &mut meter()).is_err());
let ratio = left.divide(&right, &mut meter()).unwrap();
assert_eq!(ratio.value_type(), &NumericType::Number);
assert_fraction(ratio.coordinate(), 1, 1);
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
assert_eq!(
asserted.value_type(),
&NumericType::Named(named_ratio.clone())
);
assert!(asserted.add(&ratio, &mut meter()).is_err());
assert_eq!(
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
NumericValue::number(source("1"))
);
}
#[test]
fn numerical_power_types_and_dimension_overflow_are_checked() {
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let value = named(2, &length);
assert_eq!(
value.power(0, &mut meter()).unwrap().value_type(),
&NumericType::Number
);
assert_eq!(
value.power(1, &mut meter()).unwrap().value_type(),
value.value_type()
);
assert_eq!(
value.power(-2, &mut meter()).unwrap().value_type(),
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
);
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
assert!(
named(1, &large_dimension)
.multiply(&value, &mut meter())
.is_err()
);
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
}
-1
View File
@@ -1,5 +1,4 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis_numeric;
pub mod data;
pub mod language;
pub mod model;
-864
View File
@@ -1,864 +0,0 @@
//! Pure, bounded allocation-drag composition. See the v2 interaction contract.
//!
//! These values are not input capabilities. The App must independently admit a
//! clean press and retire its token on every document/input/camera lifetime
//! change. Cache a SemanticPlan per token and destination; only `place` belongs
//! on pointer motion. `finish` rechecks the live accepted Project and commands,
//! but Document::execute still owns source admission and atomic history.
use oa_core::{
projection::{self, ProjectedNode, ProjectedView},
validation::{self, Issue, Severity},
view_options::ViewIndex,
*,
};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Destination {
LayoutOnly,
Performer(Id),
OutsidePerformers,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Uniform {
Assigned(Id),
Unassigned,
MixedOrUnresolved,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Leaf {
pub direct: Option<Relation>,
pub role_assignments: Vec<Relation>,
pub role_realizations: Vec<Relation>,
pub effective_performer: Option<Id>,
}
/// Exact semantic scope S, leaf scope L and active placement scope Q, including
/// folded/unplaced leaves. Retaining records detects first-child responsibility
/// transfer. Revision and input-epoch guards remain the caller's responsibility.
#[derive(Debug, Clone, PartialEq)]
pub struct Scope {
pub project: Id,
pub work: Id,
pub placement: Id,
pub work_records: BTreeMap<Id, Element>,
pub leaves: BTreeMap<Id, Leaf>,
pub placements: BTreeMap<Id, NodePlacement>,
pub diagram: Diagram,
pub uniform: Uniform,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedRecord {
pub id: Id,
pub name: String,
pub category: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Refusal {
Ineligible(String),
StaleScope,
MixedLayoutRequired,
RoleExitRequired {
work: Id,
roles: Vec<Id>,
performers: Vec<Id>,
message: String,
},
MissingAllocationId(Id),
InvalidCandidate(String),
CollateralChangeRequired(Vec<ChangedRecord>),
LayoutRequired(String),
}
pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
if !root.kind().is_work()
|| d.projection != ProjectionKind::Allocation
|| d.layer != root.layer()
{
return Err(Refusal::Ineligible(
"Select an activity or function in an Allocation view.".into(),
));
}
if d.nodes.get(&placement).is_none_or(|n| n.element != work) {
return Err(Refusal::StaleScope);
}
let mut work_records = BTreeMap::new();
let mut leaves = BTreeMap::new();
let mut pending = vec![work];
while let Some(id) = pending.pop() {
let element = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
if element.kind() != root.kind() || work_records.insert(id, element.clone()).is_some() {
return Err(Refusal::StaleScope);
}
let children: Vec<_> = p
.children(id)
.filter(|e| e.kind() == root.kind())
.map(|e| e.id)
.collect();
if !children.is_empty() {
pending.extend(children);
continue;
}
let direct: Vec<_> = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::Allocation && r.target() == id)
.cloned()
.collect();
if direct.len() > 1 {
return Err(Refusal::StaleScope);
}
let role_assignments: Vec<_> = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == id)
.cloned()
.collect();
let roles: BTreeSet<_> = role_assignments.iter().map(Relation::source).collect();
let role_realizations = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target()))
.cloned()
.collect();
leaves.insert(
id,
Leaf {
direct: direct.into_iter().next(),
role_assignments,
role_realizations,
effective_performer: validation::effective_performer(p, id),
},
);
}
if leaves.is_empty() {
return Err(Refusal::StaleScope);
}
let uniform = if leaves
.values()
.all(|l| l.direct.is_none() && l.role_assignments.is_empty())
{
Uniform::Unassigned
} else if let Some(performer) = leaves.values().next().and_then(|l| l.effective_performer)
&& leaves
.values()
.all(|l| l.effective_performer == Some(performer))
{
Uniform::Assigned(performer)
} else {
Uniform::MixedOrUnresolved
};
let placements = d
.nodes
.iter()
.filter(|(_, n)| work_records.contains_key(&n.element))
.map(|(id, n)| (*id, n.clone()))
.collect();
Ok(Scope {
project: p.id,
work,
placement,
work_records,
leaves,
placements,
diagram: d.clone(),
uniform,
})
}
#[derive(Debug)]
pub struct SemanticPlan {
pub scope: Scope,
pub destination: Destination,
pub label: String,
pub warnings: Vec<Issue>,
accepted: Project,
candidate: Project,
index: ViewIndex,
commands: Vec<Command>,
before_visible: ProjectedView,
before_expanded: ProjectedView,
}
impl SemanticPlan {
/// All preview drawing, ports and routing must use this candidate and index.
pub fn project(&self) -> &Project {
&self.candidate
}
pub fn index(&self) -> &ViewIndex {
&self.index
}
}
/// Allocation identities are allocated once by the gesture owner, keyed by leaf,
/// and reused on target revisits and release. No random identity generation here.
pub fn plan_semantics(
p: &Project,
scope: &Scope,
destination: Destination,
allocation_ids: &BTreeMap<Id, Id>,
) -> Result<SemanticPlan, Refusal> {
if &capture(p, scope.diagram.id, scope.work, scope.placement)? != scope {
return Err(Refusal::StaleScope);
}
if scope.work_records.len() > 1 && scope.uniform == Uniform::MixedOrUnresolved {
return Err(Refusal::MixedLayoutRequired);
}
if let Destination::Performer(id) = destination {
let valid = p
.elements
.get(&id)
.is_some_and(|e| e.kind().is_performer() && e.layer() == scope.diagram.layer);
if !valid {
return Err(Refusal::Ineligible(
"Choose a performer in this layer.".into(),
));
}
}
let before_index = ViewIndex::new(p);
let before_visible = projection::project_diagram_with_index(p, &scope.diagram, &before_index);
let before_expanded = expanded_view(p, &scope.diagram, scope, &before_index);
if scope.work_records.len() > 1 && !coherent(scope, &before_expanded) {
return Err(Refusal::MixedLayoutRequired);
}
let same_performer =
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
let mut expected = p.clone();
let mut commands = Vec::new();
let mut deleted = BTreeSet::new();
if destination != Destination::LayoutOnly && !same_performer {
for (work, leaf) in &scope.leaves {
match destination {
Destination::Performer(performer) => {
if let Some(existing) = &leaf.direct {
if existing.source() != performer {
let mut edited = existing.clone();
edited.body = RelationBody::Allocation {
source: performer,
target: *work,
};
expected.relations.insert(edited.id, edited.clone());
commands.push(Command::EditRelation(edited.id, edited));
}
} else {
let id = *allocation_ids
.get(work)
.ok_or(Refusal::MissingAllocationId(*work))?;
if id.is_nil() || expected.contains(id) {
return Err(Refusal::InvalidCandidate(
"The gesture's allocation identity is already in use.".into(),
));
}
let name = format!("{} performs {}", p.named(performer), p.named(*work));
let relation = Relation {
id,
alias: expected.fresh_alias(&name, "allocation", id),
name,
description: String::new(),
body: RelationBody::Allocation {
source: performer,
target: *work,
},
};
expected.relations.insert(id, relation.clone());
commands.push(Command::CreateRelation(relation));
}
}
Destination::OutsidePerformers => {
if !leaf.role_assignments.is_empty() {
let roles: Vec<_> = leaf
.role_assignments
.iter()
.map(Relation::source)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let performers: Vec<_> = leaf
.role_realizations
.iter()
.map(Relation::source)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let message = if leaf.direct.is_some() && performers.len() == 1 {
format!(
"Removing direct assignment would place {} in {} through its role.",
p.named(*work),
p.named(performers[0])
)
} else {
format!(
"{} retains role responsibility through {}{}; edit that responsibility before moving outside performers.",
p.named(*work),
roles
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", "),
if performers.is_empty() {
String::new()
} else {
format!(
" ({})",
performers
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
)
}
)
};
return Err(Refusal::RoleExitRequired {
work: *work,
roles,
performers,
message,
});
}
if let Some(existing) = &leaf.direct {
expected.relations.remove(&existing.id);
deleted.insert(existing.id);
commands.push(Command::Delete(existing.id));
}
}
Destination::LayoutOnly => unreachable!(),
}
}
}
// A route cannot survive the intentional deletion of its own edge, even in
// another diagram. No other saved-view cleanup is authorized by this drag.
for diagram in expected.diagrams.values_mut() {
diagram.routes.retain(|r| !deleted.contains(&r.edge));
}
let actual = prepare_command(p, &Command::Batch(commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
let retained_roles = scope
.leaves
.values()
.any(|l| !l.role_assignments.is_empty());
let label = match destination {
Destination::LayoutOnly => "Arrange only".into(),
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
Destination::Performer(id) => format!(
"Assign {} leaf {} to {}{}",
scope.leaves.len(),
match (scope.diagram.layer, scope.leaves.len()) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
},
p.named(id),
if retained_roles {
" — explicit assignment; role responsibility retained"
} else {
""
}
),
Destination::OutsidePerformers if commands.is_empty() => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
};
let warnings = validation::validate(&actual)
.into_iter()
.filter(|i| i.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&actual);
Ok(SemanticPlan {
scope: scope.clone(),
destination,
label,
warnings,
accepted: p.clone(),
candidate: actual,
index,
commands,
before_visible,
before_expanded,
})
}
/// World-space rectangle in the gesture's frozen transform. The UI resolver
/// owns half-open pointer hits, semantic target precedence and occlusion.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub position: Position,
pub size: Size,
}
impl Rect {
fn valid(self) -> bool {
[
self.position.x,
self.position.y,
self.size.width,
self.size.height,
]
.iter()
.all(|n| n.is_finite() && n.abs() <= MAX_COORDINATE)
&& self.size.width > 0.
&& self.size.height > 0.
}
fn contains(self, other: Self) -> bool {
other.position.x >= self.position.x
&& other.position.y >= self.position.y
&& other.position.x + other.size.width <= self.position.x + self.size.width
&& other.position.y + other.size.height <= self.position.y + self.size.height
}
fn overlaps(self, other: Self) -> bool {
self.position.x < other.position.x + other.size.width
&& other.position.x < self.position.x + self.size.width
&& self.position.y < other.position.y + other.size.height
&& other.position.y < self.position.y + self.size.height
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TargetPresentation {
pub placement: Id,
/// Exact frozen painted label bounds, supplied by the renderer/resolver.
pub label: Rect,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PresentationContext {
/// Visible world rectangle derived from the actual captured pan/zoom/bounds.
pub viewport: Rect,
pub zoom: f32,
pub target: Option<TargetPresentation>,
}
#[derive(Debug)]
pub struct PlacementPlan {
pub diagram: Diagram,
pub view: ProjectedView,
pub delta: Position,
pub unfolded: Option<Id>,
requested: Position,
presentation: PresentationContext,
commands: Vec<Command>,
}
/// `requested` is the original selected placement position plus pointer motion
/// in the frozen transform (therefore preserving the press offset). Snap only
/// this root; apply the resulting identical delta to captured Q and its routes.
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
pub fn place(
plan: &SemanticPlan,
requested: Position,
presentation: PresentationContext,
) -> Result<PlacementPlan, Refusal> {
if !requested.x.is_finite()
|| !requested.y.is_finite()
|| !presentation.viewport.valid()
|| !presentation.zoom.is_finite()
|| presentation.zoom <= 0.
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let root = &plan.scope.placements[&plan.scope.placement];
let delta = Position {
x: (requested.x / 10.).round() * 10. - root.position.x,
y: (requested.y / 10.).round() * 10. - root.position.y,
};
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
let mut commands = Vec::new();
let mut deltas = BTreeMap::new();
for (id, original) in &plan.scope.placements {
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
node.position = Position {
x: original.position.x + delta.x,
y: original.position.y + delta.y,
};
if !(Rect {
position: node.position,
size: node.size,
})
.valid()
{
return Err(Refusal::InvalidCandidate(
"Moved coordinates exceed the model bounds.".into(),
));
}
deltas.insert(*id, delta);
if node != original {
commands.push(Command::EditNode {
diagram: diagram.id,
original: *id,
node: node.clone(),
});
}
}
// Resolve endpoints using the captured accepted semantics, not a newly
// regrouped descendant walk. Own-edge deletions were removed above already.
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
if route.waypoints.iter().any(|p| {
!p.x.is_finite()
|| !p.y.is_finite()
|| p.x.abs() > MAX_COORDINATE
|| p.y.abs() > MAX_COORDINATE
}) {
return Err(Refusal::InvalidCandidate(
"Moved route exceeds the model bounds.".into(),
));
}
*existing = route.clone();
commands.push(Command::SetEdgeRoute {
diagram: diagram.id,
route,
});
}
}
let mut unfolded = None;
if let Destination::Performer(performer) = plan.destination {
let target = presentation.target.ok_or_else(|| {
Refusal::LayoutRequired("The frozen performer label must be visible.".into())
})?;
let node = diagram
.nodes
.get_mut(&target.placement)
.ok_or(Refusal::StaleScope)?;
let frozen_header = plan
.before_visible
.nodes
.iter()
.find(|n| n.id == target.placement)
.map(|n| Rect {
position: n.position,
size: Size {
width: n.size.width,
height: n.size.height.min(62.),
},
});
if node.element != performer
|| frozen_header.is_none_or(|header| !header.contains(target.label))
|| !target.label.valid()
|| !presentation.viewport.contains(target.label)
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
if node.collapsed && !plan.commands.is_empty() {
node.collapsed = false;
unfolded = Some(node.id);
commands.push(Command::EditNode {
diagram: diagram.id,
original: node.id,
node: node.clone(),
});
}
} else if presentation.target.is_some() {
return Err(Refusal::Ineligible(
"This layout/outside destination has no performer target.".into(),
));
}
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
let expanded = expanded_view(&plan.candidate, &diagram, &plan.scope, &plan.index);
check_geometry(plan, &view, &expanded, delta)?;
let selected = view
.nodes
.iter()
.find(|n| n.id == plan.scope.placement)
.ok_or_else(|| {
Refusal::LayoutRequired("The selected work header would be hidden.".into())
})?;
let header = Rect {
position: selected.position,
size: Size {
width: selected.size.width,
height: selected.size.height.min(62.),
},
};
if !presentation.viewport.contains(header) {
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
// A performer may legitimately enclose the work, but its frozen feedback
// label is painted above the candidate. Check their joint readability after
// projection, including same-performer moves; never shift either rectangle.
if presentation
.target
.is_some_and(|target| header.overlaps(target.label))
{
return Err(Refusal::LayoutRequired(
"The work header overlaps the performer label. Aim below or beside the label, then drag again."
.into(),
));
}
Ok(PlacementPlan {
diagram,
view,
delta,
unfolded,
requested,
presentation,
commands,
})
}
/// Final semantic/presentation equality gate, after the App has checked its
/// current token, owner, revision, clean drafts, frozen target and transform.
/// Recompute geometry to avoid accepting a modified/stale placement result.
/// None means a snapped no-op: the caller must not execute or add history.
pub fn finish(
plan: &SemanticPlan,
live: &Project,
placement: &PlacementPlan,
) -> Result<Option<Command>, Refusal> {
if live != &plan.accepted {
return Err(Refusal::StaleScope);
}
let checked = place(plan, placement.requested, placement.presentation)?;
if checked.diagram != placement.diagram
|| checked.view != placement.view
|| checked.delta != placement.delta
|| checked.unfolded != placement.unfolded
|| checked.commands != placement.commands
{
return Err(Refusal::StaleScope);
}
let mut expected = plan.candidate.clone();
expected
.diagrams
.insert(checked.diagram.id, checked.diagram);
let mut commands = plan.commands.clone();
commands.extend(checked.commands);
let command = Command::Batch(commands);
let actual = prepare_command(live, &command)
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
Ok((actual != *live).then_some(command))
}
fn expanded_view(
p: &Project,
diagram: &Diagram,
scope: &Scope,
index: &ViewIndex,
) -> ProjectedView {
let mut expanded = diagram.clone();
for id in scope.placements.keys() {
if let Some(node) = expanded.nodes.get_mut(id) {
node.collapsed = false;
}
}
projection::project_diagram_with_index(p, &expanded, index)
}
fn ancestors(view: &ProjectedView) -> BTreeMap<Id, BTreeSet<Id>> {
let parents: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
parents
.keys()
.map(|node| {
let mut current = parents[node];
let mut seen = BTreeSet::new();
while let Some(id) = current {
if !seen.insert(id) {
break;
}
current = parents.get(&id).copied().flatten();
}
(*node, seen)
})
.collect()
}
fn coherent(scope: &Scope, view: &ProjectedView) -> bool {
let ancestors = ancestors(view);
scope.placements.keys().all(|id| {
ancestors
.get(id)
.is_some_and(|parents| *id == scope.placement || parents.contains(&scope.placement))
})
}
fn overlap(a: &ProjectedNode, b: &ProjectedNode) -> f32 {
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
- a.position.x.max(b.position.x);
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
- a.position.y.max(b.position.y);
width.max(0.) * height.max(0.)
}
fn overlaps(before: &ProjectedView, after: &ProjectedView) -> bool {
// Build geometry ancestry once, avoiding a repeated descendant walk for
// every pair during pointer motion. Pairwise collision checking is bounded
// to these two projected scenes and does not traverse/clone model semantics.
let before_ancestors = ancestors(before);
let after_ancestors = ancestors(after);
let prior_nodes: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
for (i, a) in after.nodes.iter().enumerate() {
for b in &after.nodes[i + 1..] {
if after_ancestors[&a.id].contains(&b.id) || after_ancestors[&b.id].contains(&a.id) {
continue;
}
let prior = match (prior_nodes.get(&a.id), prior_nodes.get(&b.id)) {
(Some(x), Some(y))
if !before_ancestors[&x.id].contains(&y.id)
&& !before_ancestors[&y.id].contains(&x.id) =>
{
overlap(x, y)
}
_ => 0.,
};
if overlap(a, b) > prior + 0.01 {
return true;
}
}
}
false
}
fn check_geometry(
plan: &SemanticPlan,
visible: &ProjectedView,
expanded: &ProjectedView,
delta: Position,
) -> Result<(), Refusal> {
if visible.nodes.iter().chain(&expanded.nodes).any(|n| {
!(Rect {
position: n.position,
size: n.size,
})
.valid()
}) {
return Err(Refusal::InvalidCandidate(
"The projected enclosure exceeds the model bounds.".into(),
));
}
if plan.scope.work_records.len() > 1 {
if !coherent(&plan.scope, expanded) {
return Err(Refusal::MixedLayoutRequired);
}
for id in plan.scope.placements.keys() {
let old = plan
.before_expanded
.nodes
.iter()
.find(|n| n.id == *id)
.ok_or(Refusal::MixedLayoutRequired)?;
let new = expanded
.nodes
.iter()
.find(|n| n.id == *id)
.ok_or(Refusal::MixedLayoutRequired)?;
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|| (new.size.width - old.size.width).abs() > 0.01
|| (new.size.height - old.size.height).abs() > 0.01
{
return Err(Refusal::LayoutRequired("This work subtree requires an explicit layout; its footprint cannot be translated unchanged.".into()));
}
}
}
if overlaps(&plan.before_visible, visible) || overlaps(&plan.before_expanded, expanded) {
return Err(Refusal::LayoutRequired("Make room before moving work: the candidate would introduce or enlarge overlapping cards.".into()));
}
Ok(())
}
/// Compare complete records, not just removed IDs. Deleting an Allocation can
/// rewrite a surviving Constraint, remove artifacts/traces or prune view options.
fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
if expected == actual {
return Ok(());
}
let mut records = Vec::new();
fn changes<T: PartialEq>(
expected: &BTreeMap<Id, T>,
actual: &BTreeMap<Id, T>,
category: &'static str,
project: &Project,
other: &Project,
records: &mut Vec<ChangedRecord>,
) {
let ids: BTreeSet<_> = expected.keys().chain(actual.keys()).copied().collect();
for id in ids {
if expected.get(&id) != actual.get(&id) {
records.push(ChangedRecord {
id,
name: if project.contains(id) {
project.named(id)
} else {
other.named(id)
}
.into(),
category,
});
}
}
}
changes(
&expected.elements,
&actual.elements,
"element",
expected,
actual,
&mut records,
);
changes(
&expected.relations,
&actual.relations,
"relation",
expected,
actual,
&mut records,
);
changes(
&expected.diagrams,
&actual.diagrams,
"diagram",
expected,
actual,
&mut records,
);
changes(
&expected.artifact_links,
&actual.artifact_links,
"artifact",
expected,
actual,
&mut records,
);
changes(
&expected.traces,
&actual.traces,
"trace",
expected,
actual,
&mut records,
);
if expected.schema_version != actual.schema_version
|| expected.id != actual.id
|| expected.alias != actual.alias
|| expected.name != actual.name
|| expected.description != actual.description
{
records.push(ChangedRecord {
id: expected.id,
name: expected.name.clone(),
category: "project",
});
}
Err(Refusal::CollateralChangeRequired(records))
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
-266
View File
@@ -1,266 +0,0 @@
//! A narrow dispatch boundary for Allocation work gestures. Iced rows visit
//! siblings after capture; refusing inside Canvas alone still blurs an inspector
//! field. Refuse before dispatching that press to any ordinary child instead.
use crate::{
app::{
App, Message,
allocation_drag::{self, Event as WorkEvent},
},
diagram::DiagramCanvas,
};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Id, Operation, Tree, tree},
};
use iced::{
Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
};
pub const CANVAS_ID: &str = "allocation-work-canvas";
pub const ARRANGE_SHORTCUT: &str = "Alt+A";
struct Root<'a> {
app: &'a App,
content: Element<'a, Message>,
}
#[derive(Default)]
struct State {
pointer: Option<Point>,
refused_left: bool,
}
#[derive(Default)]
struct CanvasBounds(Option<Rectangle>);
impl Operation for CanvasBounds {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if id == Some(&Id::new(CANVAS_ID)) {
self.0 = Some(bounds);
}
}
}
pub fn root<'a>(app: &'a App, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
Element::new(Root {
app,
content: content.into(),
})
}
fn arrange_key(event: &Event) -> bool {
matches!(event, Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(key), modifiers, repeat: false, ..
}) if key.eq_ignore_ascii_case("a") && *modifiers == keyboard::Modifiers::ALT)
}
impl Root<'_> {
fn dirty_object(&self, bounds: Rectangle, screen: Point) -> bool {
let Some(diagram) = self.app.active_diagram else {
return false;
};
if !allocation_drag::contains(bounds, screen) {
return false;
}
let canvas = DiagramCanvas {
allocation: Some(self.app.allocation_capability()),
project: self.app.project(),
view_index: Some(self.app.document.view_index()),
diagram,
selected: self.app.selected,
zoom: self.app.zoom,
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
editable: false,
placing: false,
connection: None,
};
canvas.work_dirty_point(bounds, screen)
}
}
impl Widget<Message, Theme, Renderer> for Root<'_> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
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: &Renderer,
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: &Renderer,
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: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
match event {
Event::Mouse(mouse::Event::CursorMoved { position }) => state.pointer = Some(*position),
Event::Mouse(mouse::Event::CursorLeft) => state.pointer = None,
Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position()
}
Event::Window(iced::window::Event::Unfocused) => {
state.pointer = None;
state.refused_left = false;
}
_ => {}
}
if state.refused_left {
if matches!(
event,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
) {
state.refused_left = false;
shell.capture_event();
return;
}
if matches!(event, Event::Mouse(mouse::Event::CursorMoved { .. })) {
shell.capture_event();
return;
}
}
if self.app.allocation_input_owner() && !shell.is_event_captured() {
if arrange_key(event) {
// App rechecks clean ownership before arming. A dirty field
// retains its actual widget focus and selection on refusal.
shell.publish(Message::WorkDrag(WorkEvent::ArrangeNext));
shell.capture_event();
return;
}
if self.app.allocation_drag.arrange_next
&& matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
)
{
shell.publish(Message::Escape);
shell.capture_event();
return;
}
if !self.app.allocation_drag_clean()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
&& let Some(screen) = state.pointer.or_else(|| cursor.position())
{
let mut canvas = CanvasBounds::default();
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut canvas,
);
if canvas
.0
.is_some_and(|bounds| self.dirty_object(bounds, screen))
{
state.refused_left = true;
shell.publish(Message::WorkDrag(WorkEvent::Refused(
self.app.allocation_drag.epoch,
allocation_drag::FINISH_EDIT.into(),
)));
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 Renderer,
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: &Renderer,
) -> 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: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
+41 -330
View File
@@ -1,15 +1,10 @@
pub(crate) mod allocation_drag;
pub(crate) mod architecture_authoring;
#[cfg(test)]
mod architecture_authoring_tests;
mod checkpoints;
pub(crate) mod diagram_removal;
#[cfg(test)]
mod diagram_removal_tests;
#[cfg(test)]
mod diagram_selection_tests;
mod hosted;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
#[cfg(test)]
@@ -62,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);
@@ -78,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(),
@@ -126,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));
@@ -331,8 +318,6 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
WorkDrag(allocation_drag::Event),
BehaviorControl(behavior::canvas_workspace::Intent),
Architecture(architecture_authoring::Event),
Home(crate::home::Event),
CheckpointTick,
@@ -386,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,
@@ -396,9 +379,7 @@ pub enum Message {
RelationKind(RelationKind),
Command(Command),
Delete,
ConfirmDelete(u64),
DiagramRemove(diagram_removal::Intent),
DiagramTraverse(bool),
ConfirmDelete,
CancelDelete,
Undo,
Redo,
@@ -457,7 +438,6 @@ pub enum Message {
}
pub struct App {
pub allocation_drag: allocation_drag::State,
pub home: crate::home::State,
pub recovery: crate::recovery::State,
recent_target: Option<PathBuf>,
@@ -465,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,
@@ -497,7 +473,6 @@ pub struct App {
pub busy: bool,
pub pending: Option<Pending>,
pub delete_pending: bool,
pub delete_target: Option<diagram_removal::Confirmation>,
pub path: Option<PathBuf>,
/// A disk transaction requiring an explicit action, independent of lint.
pub pending_recovery: Option<PathBuf>,
@@ -566,20 +541,16 @@ impl App {
let explorer_index = crate::explorer::Index::new(&project, layer);
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
let mut app = Self {
allocation_drag: Default::default(),
home: Default::default(),
recovery: Default::default(),
recent_target: None,
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,
@@ -608,7 +579,6 @@ impl App {
busy: false,
pending: None,
delete_pending: false,
delete_target: None,
path: None,
pending_recovery: None,
disk_project: None,
@@ -884,12 +854,8 @@ impl App {
let scenario_selection = self
.behavior
.scenario
.created_selection(self.project(), &command)
.or_else(|| {
self.behavior
.topology
.created_selection(self.project(), &command)
});
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(created) = created {
@@ -1057,13 +1023,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([
@@ -1071,21 +1031,9 @@ impl App {
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
iced::event::listen_with(|event, _, _| {
matches!(
event,
iced::Event::Keyboard(keyboard::Event::KeyPressed { .. })
)
.then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary))
}),
behavior::creation::subscription(
self.behavior.creation.has_pending() || self.behavior.source_active(),
)
.map(|()| Message::Escape),
behavior::creation::subscription(self.behavior.creation.has_pending())
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
if matches!(event, iced::Event::Window(window::Event::Unfocused)) {
return Some(Message::WorkDrag(allocation_drag::Event::Unfocused));
}
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers: mods,
@@ -1129,116 +1077,6 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
self.allocation_before(&message);
if let Message::WorkDrag(event) = message {
return self.allocation_event(event);
}
let allocation_before = self.allocation_boundary();
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 self.allocation_boundary() != allocation_before {
self.allocation_retire();
}
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
@@ -1373,8 +1211,6 @@ impl App {
}
if self.delete_pending {
self.delete_pending = false;
self.delete_target = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::none();
}
if self.pending.take().is_some() {
@@ -1409,7 +1245,7 @@ impl App {
Message::FilePath(_) | Message::SubmitFile | Message::CancelFile
))
|| (self.delete_pending
&& !matches!(message, Message::ConfirmDelete(_) | Message::CancelDelete))
&& !matches!(message, Message::ConfirmDelete | Message::CancelDelete))
|| (self.pending.is_some()
&& self.file_prompt.is_none()
&& !matches!(
@@ -1463,7 +1299,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;
@@ -1531,24 +1366,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::WorkDrag(event) => return self.allocation_event(event),
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(),
@@ -1564,18 +1383,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
@@ -1600,9 +1407,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
@@ -1613,49 +1420,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();
}
@@ -1669,11 +1433,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()
@@ -1764,22 +1526,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(()) => {
@@ -2001,15 +1754,7 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
return Task::none();
}
if self.behavior.source_active() {
self.behavior.acknowledge_commit();
self.status = "Behavior form cancelled; no staged source was created.".into();
} else if self.behavior.creation.has_pending() {
if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
}
@@ -2072,9 +1817,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
@@ -2315,7 +2057,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());
@@ -2579,13 +2321,10 @@ impl App {
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
return Task::none();
}
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
&& (self.source_dirty() || self.draft_dirty())
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
{
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
return Task::none();
}
let source_was_open = self.behavior.source_open();
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
return Task::none();
@@ -2603,11 +2342,6 @@ impl App {
}
}
Ok(None) => {
if !source_was_open && self.behavior.source_open() {
return iced::widget::operation::focus(
behavior::source_draft::NAME_INPUT,
);
}
if focus_name {
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
}
@@ -2787,41 +2521,29 @@ impl App {
}
}
}
Message::DiagramRemove(intent) => self.remove_from_diagram(intent),
Message::DiagramTraverse(back) => {
return if back {
iced::widget::operation::focus_previous()
} else {
iced::widget::operation::focus_next()
};
}
Message::Delete => {
if self.selected != self.project().id && self.apply() {
self.begin_model_delete(None);
self.delete_pending = true;
}
}
Message::ConfirmDelete(token) => self.confirm_model_delete(token),
Message::CancelDelete => {
Message::ConfirmDelete => {
if !self.delete_pending || self.behavior.has_pending() {
return Task::none();
}
self.delete_pending = false;
self.delete_target = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.execute(Command::Delete(self.selected));
}
Message::CancelDelete => self.delete_pending = false,
Message::Undo | Message::Redo => {
let redo = matches!(message, Message::Redo);
if self.apply() {
let changed = if redo {
self.document.redo()
if redo {
self.document.redo();
} else {
self.document.undo()
};
if changed {
self.sync();
self.document.undo();
}
self.status = if !changed && redo {
"Nothing to redo"
} else if !changed {
"Nothing to undo"
} else if redo {
self.sync();
self.status = if redo {
"Model change redone"
} else {
"Model change undone"
@@ -3075,17 +2797,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}"
)
@@ -3862,7 +3578,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);
@@ -4530,10 +4245,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::*;
@@ -1,601 +0,0 @@
//! Allocation work-body input admission. Gesture authority lives here, while
//! the canvas retains ordered native pointer positions and frozen hit geometry.
use super::{App, Message, Page};
use crate::{allocation_drag as plan, route_edit::Context};
use iced::{Point, Rectangle, Task, Vector};
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
use std::{collections::BTreeMap, sync::Arc};
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
pub context: Context,
pub epoch: u64,
pub snapshot: usize,
pub bounds: Rectangle,
pub pan: Vector,
pub zoom: f32,
}
impl Stamp {
pub fn world(self, screen: Point) -> Point {
Point::new(
(screen.x - self.bounds.x - self.pan.x) / self.zoom,
(screen.y - self.bounds.y - self.pan.y) / self.zoom,
)
}
pub fn viewport(self) -> plan::Rect {
let p = self.world(Point::new(self.bounds.x, self.bounds.y));
plan::Rect {
position: Position { x: p.x, y: p.y },
size: Size {
width: self.bounds.width / self.zoom,
height: self.bounds.height / self.zoom,
},
}
}
}
#[derive(Debug, Clone)]
pub struct Target {
pub element: Id,
pub placement: Id,
pub region: Rectangle,
pub label: plan::Rect,
pub name: String,
pub ancestors: Vec<Id>,
}
#[derive(Debug, Clone)]
pub struct Frozen {
pub targets: Vec<Target>,
cards: Vec<Rectangle>,
controls: Vec<Rectangle>,
edges: Vec<(Point, Point)>,
radius: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Hit {
Destination(plan::Destination, Option<usize>),
OutsideViewport,
Occluded,
Ambiguous(Vec<usize>),
}
pub fn contains(r: Rectangle, p: Point) -> bool {
p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height
}
impl Frozen {
pub fn capture(
project: &Project,
scope: &plan::Scope,
view: &ProjectedView,
routed: &RoutedView,
zoom: f32,
) -> Self {
let mut targets = Vec::new();
let mut cards = Vec::new();
for n in &view.nodes {
if scope.placements.contains_key(&n.id) {
continue;
}
let region = Rectangle::new(
Point::new(n.position.x, n.position.y),
iced::Size::new(n.size.width, n.size.height),
);
let Some(e) = project.elements.get(&n.element) else {
cards.push(region);
continue;
};
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
let mut ancestors = Vec::new();
let mut parent = e.parent;
while let Some(id) = parent {
if ancestors.contains(&id) {
break;
}
ancestors.push(id);
parent = project.elements.get(&id).and_then(|e| e.parent);
}
// This is also the exact frozen label painted above the ghost.
let name = crate::views::short(
project.named(e.id),
(((region.width - 28.).max(10.) / 10.) as usize).min(34),
);
let label = plan::Rect {
position: Position {
x: region.x + 10.,
y: region.y + 31.,
},
size: Size {
width: (name.chars().count() as f32 * 10. + 8.)
.min((region.width - 20.).max(1.)),
height: 22.,
},
};
let collapsed = scope.diagram.nodes[&n.id].collapsed;
targets.push(Target {
element: e.id,
placement: n.id,
region: if collapsed {
Rectangle {
height: region.height.min(62.),
..region
}
} else {
region
},
label,
name,
ancestors,
});
} else {
cards.push(region);
}
}
let radius = (10. / zoom).max(10.);
let mut controls: Vec<_> = view
.ports
.iter()
.filter(|port| !scope.placements.contains_key(&port.owner))
.map(|port| {
Rectangle::new(
Point::new(port.position.x - radius, port.position.y - radius),
iced::Size::new(radius * 2., radius * 2.),
)
})
.collect();
let mut edges = Vec::new();
for edge in routed.edges.values() {
if let Some(relation) = project.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, edge) {
if !end.label.text.is_empty() {
controls.push(Rectangle::new(
Point::new(end.label.bounds.position.x, end.label.bounds.position.y),
iced::Size::new(
end.label.bounds.size.width,
end.label.bounds.size.height,
),
));
}
}
}
if !edge.label.text.is_empty() {
controls.push(Rectangle::new(
Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y),
iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
));
}
for pin in &edge.pins {
controls.push(Rectangle::new(
Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom),
iced::Size::new(16. / zoom, 16. / zoom),
));
}
for points in edge.points.windows(2) {
edges.push((
Point::new(points[0].x, points[0].y),
Point::new(points[1].x, points[1].y),
));
}
}
Self {
targets,
cards,
controls,
edges,
radius: 6. / zoom,
}
}
pub fn resolve(&self, stamp: Stamp, screen: Option<Point>, arrange: bool) -> Hit {
let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else {
return Hit::OutsideViewport;
};
let p = stamp.world(screen);
// Drag status lives in the toolbar, outside this actual canvas viewport.
if self
.controls
.iter()
.chain(&self.cards)
.any(|r| contains(*r, p))
|| self.edges.iter().any(|(a, b)| {
let d = *b - *a;
let length = d.x * d.x + d.y * d.y;
let t = if length == 0. {
0.
} else {
(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.)
};
p.distance(*a + d * t) <= self.radius
})
{
return Hit::Occluded;
}
if arrange {
return Hit::Destination(plan::Destination::LayoutOnly, None);
}
let hits: Vec<_> = self
.targets
.iter()
.enumerate()
.filter(|(_, t)| contains(t.region, p))
.map(|(i, _)| i)
.collect();
for (i, a) in hits.iter().enumerate() {
for b in &hits[i + 1..] {
let a = &self.targets[*a];
let b = &self.targets[*b];
if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) {
return Hit::Ambiguous(hits);
}
}
}
match hits
.into_iter()
.max_by_key(|i| self.targets[*i].ancestors.len())
{
Some(i) => Hit::Destination(
plan::Destination::Performer(self.targets[i].element),
Some(i),
),
None => Hit::Destination(plan::Destination::OutsidePerformers, None),
}
}
}
#[derive(Debug, Clone)]
pub struct Press {
pub stamp: Stamp,
pub token: Id,
pub start: Point,
pub scope: plan::Scope,
pub frozen: Frozen,
pub arrange: bool,
}
#[derive(Debug, Clone)]
pub enum Event {
Press(Arc<Press>),
Preview(Stamp, Id, Option<Point>),
Release(Stamp, Id, Option<Point>),
Refused(u64, String),
Cancel(Id),
ArrangeNext,
Unfocused,
KeyboardBoundary,
}
pub struct Preview {
pub token: Id,
pub semantic: Arc<plan::SemanticPlan>,
pub placement: plan::PlacementPlan,
pub routed: Arc<RoutedView>,
}
struct Active {
press: Arc<Press>,
ids: BTreeMap<Id, Id>,
cache: Vec<(
plan::Destination,
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
)>,
}
pub struct Feedback {
pub press: Arc<Press>,
pub targets: Vec<usize>,
pub text: String,
pub refused: bool,
}
#[derive(Default)]
pub struct State {
pub epoch: u64,
pub arrange_next: bool,
active: Option<Active>,
pub preview: Option<Arc<Preview>>,
pub feedback: Option<Feedback>,
}
#[derive(Clone, Copy)]
pub struct Capability<'a> {
pub state: &'a State,
pub clean: bool,
pub snapshot: usize,
}
impl State {
fn retire(&mut self) {
self.epoch = self.epoch.wrapping_add(1);
self.active = None;
self.preview = None;
self.feedback = None;
self.arrange_next = false;
}
}
pub fn refusal(r: &plan::Refusal) -> String {
match r {
plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(),
plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(),
plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(),
plan::Refusal::RoleExitRequired { message, .. } => message.clone(),
plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(),
plan::Refusal::CollateralChangeRequired(records) => format!(
"Assignment needs dependent edits to {}. Resolve these before dragging again.",
records.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ")
),
}
}
impl App {
pub fn allocation_drag_clean(&self) -> bool {
self.allocation_input_owner()
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
}
pub(crate) fn allocation_input_owner(&self) -> bool {
self.page == Page::Diagram
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
&& self.architecture.is_none()
&& self.connection.is_none()
&& self.model_menu.is_none()
&& !self.reference_input_active()
&& !self.busy
&& !self.hosted.visible
&& self.workflow.is_none()
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
}
pub fn allocation_capability(&self) -> Capability<'_> {
Capability {
state: &self.allocation_drag,
clean: self.allocation_drag_clean(),
snapshot: Arc::as_ptr(&self.document.snapshot()) as usize,
}
}
fn allocation_stamp_valid(&self, stamp: Stamp) -> bool {
self.allocation_drag_clean()
&& self.route_context() == Some(stamp.context)
&& self.allocation_drag.epoch == stamp.epoch
&& Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot
&& self.pan == stamp.pan
&& self.zoom == stamp.zoom
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
&& stamp.bounds.width > 0.
&& stamp.bounds.height > 0.
}
/// Called before general input dispatch. Even equal-valued actions (Fit,
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
pub(super) fn allocation_before(&mut self, message: &Message) {
if !matches!(
message,
Message::WorkDrag(_)
| Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::CanvasResized(_)
| Message::WindowResized(_)
| Message::SourceViewport
) {
self.allocation_drag.retire();
}
}
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
(
self.route_context(),
self.pan,
self.zoom,
self.canvas_size,
self.window_size,
self.allocation_drag_clean(),
Arc::as_ptr(&self.document.snapshot()) as usize,
)
}
pub(super) fn allocation_retire(&mut self) {
self.allocation_drag.retire();
}
fn select_work_clean(&mut self, press: &Press) -> bool {
if !self.allocation_stamp_valid(press.stamp) {
return false;
}
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.selected_bend = None;
self.refresh_draft();
true
}
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::ArrangeNext => {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
} else {
self.status = FINISH_EDIT.into();
}
}
Event::Unfocused => self.allocation_drag.retire(),
Event::KeyboardBoundary => {
if self.allocation_drag.active.is_some() {
self.allocation_drag.retire();
}
}
Event::Cancel(token) => {
if self
.allocation_drag
.active
.as_ref()
.is_some_and(|a| a.press.token == token)
{
self.allocation_drag.retire();
self.status = "Work drag canceled".into();
}
}
Event::Refused(epoch, text) => {
if epoch == self.allocation_drag.epoch {
self.allocation_drag.retire();
self.status = text;
}
}
Event::Press(press) => {
if self.allocation_drag.active.is_some() {
return Task::none();
}
if !self.allocation_stamp_valid(press.stamp)
|| press.arrange != self.allocation_drag.arrange_next
{
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
} else {
FINISH_EDIT.into()
};
return Task::none();
}
let checked = plan::capture(
self.project(),
press.scope.diagram.id,
press.scope.work,
press.scope.placement,
);
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
self.status = checked
.err()
.as_ref()
.map(refusal)
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
self.allocation_drag.retire();
return Task::none();
}
let ids = press
.scope
.leaves
.keys()
.map(|id| (*id, Id::new_v4()))
.collect();
self.allocation_drag.arrange_next = false;
self.allocation_drag.preview = None;
self.allocation_drag.feedback = None;
self.allocation_drag.active = Some(Active {
press,
ids,
cache: Vec::new(),
});
}
Event::Preview(stamp, token, pointer) => {
self.allocation_motion(stamp, token, pointer, false)
}
Event::Release(stamp, token, pointer) => {
self.allocation_motion(stamp, token, pointer, true)
}
}
Task::none()
}
fn allocation_motion(
&mut self,
stamp: Stamp,
token: Id,
pointer: Option<Point>,
release: bool,
) {
if self
.allocation_drag
.active
.as_ref()
.is_none_or(|a| a.press.token != token)
{
return; // An old packet cannot cancel a newer gesture.
}
if !self.allocation_stamp_valid(stamp) {
self.allocation_drag.retire();
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
} else {
FINISH_EDIT.into()
};
return;
}
let Some(mut active) = self.allocation_drag.active.take() else {
return;
};
if active.press.token != token || active.press.stamp != stamp {
self.allocation_drag.active = Some(active);
return;
}
let press = active.press.clone();
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
let mut targets = Vec::new();
let mut preview = None;
let result: Result<String, String> = match hit {
Hit::OutsideViewport => Err("Work drag canceled outside the canvas.".into()),
_ if !moved => Ok("Work selected".into()),
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
Hit::Ambiguous(indices) => {
targets = indices;
Err("Two unrelated performers overlap here. Use an exposed region.".into())
}
Hit::Destination(destination, target) => {
targets.extend(target);
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
result.clone()
} else {
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
active.cache.push((destination, result.clone()));
result
};
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
if stamp.zoom * 16. < 9. {
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
}
let pointer = pointer.unwrap();
let delta = pointer - press.start;
let original = press.scope.placements[&press.scope.placement].position;
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
let presentation = plan::PresentationContext {
viewport: stamp.viewport(), zoom: stamp.zoom,
target: target.map(|i| {
let t = &press.frozen.targets[i];
plan::TargetPresentation { placement: t.placement, label: t.label }
}),
};
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
};
if release {
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
if let Some(command) = command {
// Bypass applying selectors and unrelated repair helpers. The
// planner's exact Batch enters the authoritative source pipeline.
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
self.sync();
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.refresh_draft();
}
} else {
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
}
Ok(label)
})
}
};
let refused = result.is_err();
let text = result.unwrap_or_else(|text| text);
self.status = text.clone();
if release {
self.allocation_drag.retire();
} else {
self.allocation_drag.active = Some(active);
self.allocation_drag.preview = preview;
self.allocation_drag.feedback = Some(Feedback {
press,
targets,
text,
refused,
});
}
}
}
#[cfg(test)]
#[path = "allocation_drag_tests.rs"]
mod tests;
@@ -1,290 +0,0 @@
//! Actual App root, retained Iced tree, property widgets and production renderer.
//! Unlike packet-only tests, these prove the next key still reaches the field.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard};
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(text.into()),
modified_key: keyboard::Key::Character(text.into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers,
text: Some(text.into()),
repeat: false,
})
}
async fn mount() -> (Fixture, Mounted) {
let mut f = fixture(Layer::Oa);
f.app.window_size = iced::Size::new(2000., 1400.);
let _ = f.app.update(Message::Select(f.work));
let mut mounted = Mounted::new(&mut f.app).await;
// First sensor measurement intentionally Fits; now use a fixed known camera.
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
mounted.settle(&mut f.app).await;
(f, mounted)
}
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.bounds(crate::allocation_input::CANVAS_ID)
}
fn at(mounted: &mut Mounted, f: &Fixture, world: Position) -> Point {
let bounds = canvas_bounds(mounted, &f.app);
Point::new(
bounds.x + world.x * f.app.zoom + f.app.pan.x,
bounds.y + world.y * f.app.zoom + f.app.pan.y,
)
}
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.text_inputs
.into_iter()
.filter(|b| b.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("actual inspector Name TextInput")
}
fn description_field(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.focused
.into_iter()
.map(|(_, bounds, _)| bounds)
.filter(|b| b.x > app.window_size.width - 320. && b.height >= 90.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("actual inspector Description TextEditor")
}
fn is_focused(mounted: &mut Mounted, app: &App, field: Rectangle) -> bool {
mounted
.geometry(app)
.focused
.iter()
.any(|(_, bounds, focused)| *bounds == field && *focused)
}
async fn type_key(mounted: &mut Mounted, app: &mut App, text: &str) -> Vec<Message> {
mounted
.batch(
app,
&[character(text, keyboard::Modifiers::empty())],
mouse::Cursor::Unavailable,
)
.await
}
async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field: Rectangle) {
let before = f.app.document.bundle().clone();
let selected = (f.app.selected, f.app.selected_node);
let point = at(mounted, f, xy(140., 535.));
let messages = mounted.click(&mut f.app, point).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
)));
assert_eq!(f.app.document.bundle(), &before);
assert_eq!((f.app.selected, f.app.selected_node), selected);
assert!(
is_focused(mounted, &f.app, field),
"refused press must not reach the outside-press unfocus handler"
);
}
#[tokio::test]
async fn actual_app_tree_dirty_name_retains_focus_and_middle_caret_for_next_key() {
let (mut f, mut mounted) = mount().await;
let field = name_field(&mut mounted, &f.app);
mounted.click(&mut f.app, field.center()).await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Home)],
mouse::Cursor::Unavailable,
)
.await;
type_key(&mut mounted, &mut f.app, "X").await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::ArrowLeft)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XWork");
refused_click_keeps_field(&mut mounted, &mut f, field).await;
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
);
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXWork");
assert!(!f.app.document.can_undo());
}
#[tokio::test]
async fn actual_app_tree_dirty_description_retains_focus_and_middle_caret_for_next_key() {
let (mut f, mut mounted) = mount().await;
let field = description_field(&mut mounted, &f.app);
mounted
.click(&mut f.app, Point::new(field.x + 16., field.y + 16.))
.await;
type_key(&mut mounted, &mut f.app, "A").await;
type_key(&mut mounted, &mut f.app, "B").await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::ArrowLeft)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
"AB"
);
refused_click_keeps_field(&mut mounted, &mut f, field).await;
let messages = type_key(&mut mounted, &mut f.app, "X").await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::FieldText(path, _) if path == "/description"))
);
assert_eq!(
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
"AXB"
);
assert!(!f.app.document.can_undo());
}
#[tokio::test]
async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty_owner_refuses() {
let (mut f, mut mounted) = mount().await;
let shortcut = character("a", keyboard::Modifiers::ALT);
let messages = mounted
.batch(&mut f.app, &[shortcut.clone()], mouse::Cursor::Unavailable)
.await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::ArrangeNext)))
);
assert!(f.app.allocation_drag.arrange_next);
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Escape)],
mouse::Cursor::Unavailable,
)
.await;
assert!(!f.app.allocation_drag.arrange_next);
mounted
.batch(&mut f.app, &[shortcut.clone()], mouse::Cursor::Unavailable)
.await;
let from = at(&mut mounted, &f, xy(140., 535.));
let to = from + Vector::new(30., 0.);
let messages = mounted
.batch(
&mut f.app,
&[
Input::Mouse(mouse::Event::CursorMoved { position: from }),
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Input::Mouse(mouse::Event::CursorMoved { position: to }),
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(to),
)
.await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
);
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
assert!(f.app.project().relations.is_empty());
let field = name_field(&mut mounted, &f.app);
mounted.click(&mut f.app, field.center()).await;
type_key(&mut mounted, &mut f.app, "X").await;
let raw = f.app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
let before = f.app.document.bundle().clone();
mounted
.batch(&mut f.app, &[shortcut], mouse::Cursor::Unavailable)
.await;
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.status, FINISH_EDIT);
assert!(is_focused(&mut mounted, &f.app, field));
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
assert_eq!(f.app.document.bundle(), &before);
}
fn canvas_pixels(pixels: &[u8], width: usize, canvas: Rectangle) -> Vec<u8> {
let x = canvas.x.ceil() as usize;
let y = canvas.y.ceil() as usize;
let right = (canvas.x + canvas.width).floor() as usize;
let bottom = (canvas.y + canvas.height).floor() as usize;
(y..bottom)
.flat_map(|row| {
pixels[(row * width + x) * 4..(row * width + right) * 4]
.iter()
.copied()
})
.collect()
}
#[tokio::test]
async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_changes() {
let (mut f, mut mounted) = mount().await;
let mut project = f.app.project().clone();
let target = project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == f.a)
.unwrap();
target.position.y = -41.;
target.size.height = 300.;
replace(&mut f, project);
f.app.zoom = 0.75;
f.app.pan = Vector::new(15., 15.);
let from = at(&mut mounted, &f, xy(140., 535.));
let to = at(&mut mounted, &f, xy(200., 80.));
mounted
.batch(
&mut f.app,
&[
Input::Mouse(mouse::Event::CursorMoved { position: from }),
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Input::Mouse(mouse::Event::CursorMoved { position: to }),
],
mouse::Cursor::Available(to),
)
.await;
let feedback = f
.app
.allocation_drag
.feedback
.as_ref()
.expect("active frozen destination");
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
assert!(target.label.position.y * f.app.zoom + f.app.pan.y < 14.);
let canvas = canvas_bounds(&mut mounted, &f.app);
let first = mounted.pixels(&f.app);
f.app.allocation_drag.feedback.as_mut().unwrap().text =
"Very different drag status, retained outside the readable canvas. ".repeat(10);
let second = mounted.pixels(&f.app);
assert_ne!(first, second, "the actual toolbar status changes");
assert_eq!(
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
canvas_pixels(&second, f.app.window_size.width as usize, canvas),
"status must not obscure any candidate header or frozen target label"
);
}
File diff suppressed because it is too large Load Diff
@@ -77,7 +77,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
@@ -232,7 +232,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
editable: true,
placing: true,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
@@ -307,7 +306,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
@@ -291,7 +291,7 @@ fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
Message::BrowseGroup(crate::explorer::Group::Work),
Message::Field("/name".into(), "Overwrite properties".into()),
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
Message::ConfirmDelete(0),
Message::ConfirmDelete,
Message::Undo,
Message::Redo,
Message::ShortcutUndo(false),
-5
View File
@@ -214,11 +214,6 @@ impl App {
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
@@ -1,152 +0,0 @@
//! Captured diagram removal and model-confirmation lifetimes.
use super::*;
use crate::{model_menu, route_edit::Context};
#[derive(Debug, Clone)]
pub struct Intent {
pub context: Context,
pub object: Id,
pub placement: Option<Id>,
pub model: bool,
}
pub struct Confirmation {
pub snapshot: model_menu::Opened,
pub selection: Id,
pub placement: Option<Id>,
}
pub fn enabled(app: &App) -> bool {
crate::behavior::canvas_workspace::keyboard_enabled(app)
&& !app.draft_dirty()
&& !app.source_dirty()
&& !app.behavior.has_pending()
&& app
.architecture
.as_ref()
.is_none_or(|state| !state.inline())
}
impl App {
pub(super) fn remove_from_diagram(&mut self, intent: Intent) {
if !enabled(self) || self.connection.is_some() || self.selected_bend.is_some() {
return;
}
let current = match self.page {
Page::Diagram if self.architecture.as_ref().is_none_or(|s| !s.saved.placing) => {
self.route_context()
}
Page::Behavior
if self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select =>
{
self.state_context()
}
_ => None,
};
if current != Some(intent.context) || self.selected != intent.object {
return;
}
let view = projection::project_view(self.project(), intent.context.diagram);
let placement = if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
};
if intent.placement != placement || intent.object == intent.context.diagram {
return;
}
let visible = if let Some(id) = intent.placement {
self.project().diagrams[&intent.context.diagram]
.nodes
.get(&id)
.is_some_and(|node| node.element == intent.object)
&& view
.nodes
.iter()
.any(|node| node.id == id && node.element == intent.object)
} else {
view.edges.iter().any(|edge| edge.id == intent.object)
|| view.ports.iter().any(|port| port.element == intent.object)
};
if !visible {
return;
}
if !intent.model
&& let Some(placement) = intent.placement
{
if self.execute(Command::Delete(placement)) {
self.selected_node = None;
self.behavior.diagram.placement = None;
self.selected_bend = None;
self.status = "Hidden from this diagram · Model element preserved · Undo restores the placement".into();
}
} else {
self.begin_model_delete(Some(intent.context));
}
}
pub(super) fn begin_model_delete(&mut self, canvas: Option<Context>) {
if self.selected == self.project().id || self.behavior.has_pending() {
return;
}
let mut request = model_menu::Request::object(self.selected, iced::Point::ORIGIN);
request.canvas = canvas;
request.placement = if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
};
// Opening a modal retires any pending canvas intent before capturing
// the confirmation; every new dialog receives its own replay token.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if canvas.is_some() {
request.canvas = if self.page == Page::Behavior {
self.state_context()
} else {
self.route_context()
};
}
self.menu_sequence = self.menu_sequence.wrapping_add(1);
self.delete_target =
model_menu::Opened::new(self, request, self.menu_sequence).map(|snapshot| {
Confirmation {
snapshot,
selection: self.selected,
placement: if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
},
}
});
self.delete_pending = self.delete_target.is_some();
}
pub(super) fn confirm_model_delete(&mut self, token: u64) {
let Some(target) = self.delete_target.as_ref() else {
return;
};
if target.snapshot.token != token {
return;
}
let valid = self.delete_pending
&& target.snapshot.valid(self)
&& target.selection == self.selected
&& target.placement
== if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
}
&& !self.behavior.has_pending()
&& !self.source_dirty()
&& !self.draft_dirty();
let id = target.snapshot.request.object;
self.delete_pending = false;
self.delete_target = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if valid {
self.execute(Command::Delete(id));
}
}
}
@@ -1,681 +0,0 @@
//! Real App tree regression tests; no private canvas focus or gesture seeding.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::advanced::widget::{Operation, operation};
use iced::{Event as Input, Point, Rectangle, Size, Vector, keyboard, mouse};
struct Fixture {
app: App,
object: Id,
node: Id,
other: Id,
diagram: Id,
copy: Id,
}
fn fixture(layer: Layer) -> Fixture {
let mut p = Project::empty();
let kind = if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
};
let object = p.add_element(kind, layer, "Target", None).unwrap();
let other = p
.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"Other",
None,
)
.unwrap();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
let node = p.place(diagram, object, Position { x: 80., y: 80. });
p.place(diagram, other, Position { x: 500., y: 80. });
let mut second = p.diagrams[&diagram].clone();
second.id = Id::new_v4();
second.name = "Other diagram".into();
second.alias = "other_diagram".into();
second.nodes.clear();
second.routes.clear();
let copy = second.id;
p.diagrams.insert(copy, second);
p.place(copy, object, Position { x: 70., y: 70. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = layer;
app.active_diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
Fixture {
app,
object,
node,
other,
diagram,
copy,
}
}
async fn mount(layer: Layer) -> (Fixture, Mounted) {
let mut f = fixture(layer);
let mut mounted = Mounted::new(&mut f.app).await;
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
mounted.settle(&mut f.app).await;
select(&mut mounted, &mut f).await;
(f, mounted)
}
async fn select(mounted: &mut Mounted, f: &mut Fixture) {
let bounds = mounted
.geometry(&f.app)
.bounds(crate::allocation_input::CANVAS_ID);
let node = projection::project_view(f.app.project(), f.diagram)
.nodes
.into_iter()
.find(|node| node.id == f.node)
.unwrap();
let point = Point::new(
bounds.x + (node.position.x + 40.) * f.app.zoom + f.app.pan.x,
bounds.y + (node.position.y + 35.) * f.app.zoom + f.app.pan.y,
);
mounted.click(&mut f.app, point).await;
assert_eq!(f.app.selected, f.object);
assert_eq!(f.app.selected_node, Some(f.node));
}
fn modified(named: keyboard::key::Named, modifiers: keyboard::Modifiers) -> Input {
let mut input = key(named);
if let Input::Keyboard(keyboard::Event::KeyPressed {
modifiers: value, ..
}) = &mut input
{
*value = modifiers;
}
input
}
async fn delete(mounted: &mut Mounted, app: &mut App, shift: bool) -> Vec<Message> {
mounted
.batch(
app,
&[modified(
keyboard::key::Named::Delete,
if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::empty()
},
)],
mouse::Cursor::Unavailable,
)
.await
}
fn consistent(app: &App) {
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
}
#[tokio::test]
async fn mounted_oa_sa_delete_hides_exact_placement_and_round_trips_source_and_model() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, mut mounted) = mount(layer).await;
let before = f.app.document.snapshot();
let messages = delete(&mut mounted, &mut f.app, false).await;
assert!(
messages
.iter()
.any(|message| matches!(message, Message::DiagramRemove(_)))
);
assert!(f.app.project().contains(f.object));
assert!(
!f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&f.node)
);
assert!(
f.app.project().diagrams[&f.copy]
.nodes
.values()
.any(|node| node.element == f.object)
);
assert!(!f.app.delete_pending);
consistent(&f.app);
let after = f.app.document.snapshot();
let task = f.app.update(Message::Undo);
mounted.perform(&mut f.app, task).await;
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo(), "one removal, one undo unit");
let task = f.app.update(Message::Redo);
mounted.perform(&mut f.app, task).await;
assert_eq!(f.app.project(), &after.decoded.project);
assert_eq!(f.app.document.bundle(), &after.bundle);
consistent(&f.app);
}
}
#[tokio::test]
async fn mounted_shift_delete_captures_target_cancel_escape_and_confirmation_replay() {
let (mut f, mut mounted) = mount(Layer::Oa).await;
let before = f.app.document.snapshot();
delete(&mut mounted, &mut f.app, true).await;
let target = f.app.delete_target.as_ref().unwrap();
assert_eq!(target.snapshot.request.object, f.object);
let stale_token = target.snapshot.token;
let task = f.app.update(Message::CancelDelete);
mounted.perform(&mut f.app, task).await;
let task = f.app.update(Message::ConfirmDelete(stale_token));
mounted.perform(&mut f.app, task).await;
assert_eq!(f.app.document.bundle(), &before.bundle);
select(&mut mounted, &mut f).await;
delete(&mut mounted, &mut f.app, true).await;
let token = f.app.delete_target.as_ref().unwrap().snapshot.token;
let task = f.app.update(Message::ConfirmDelete(stale_token));
mounted.perform(&mut f.app, task).await;
assert!(
f.app.delete_pending,
"old dialog must not accept a new confirmation"
);
let task = f.app.update(Message::ConfirmDelete(token));
mounted.perform(&mut f.app, task).await;
assert!(!f.app.project().contains(f.object));
assert!(f.app.project().contains(f.other));
consistent(&f.app);
let task = f.app.update(Message::Undo);
mounted.perform(&mut f.app, task).await;
assert_eq!(f.app.document.bundle(), &before.bundle);
select(&mut mounted, &mut f).await;
delete(&mut mounted, &mut f.app, true).await;
let task = f.app.update(Message::Escape);
mounted.perform(&mut f.app, task).await;
assert!(!f.app.delete_pending);
assert!(f.app.delete_target.is_none());
assert_eq!(f.app.document.bundle(), &before.bundle);
}
struct FocusField(Rectangle);
impl Operation for FocusField {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&iced::advanced::widget::Id>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if bounds == self.0 {
state.focus();
} else {
state.unfocus();
}
}
}
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.unwrap()
}
#[tokio::test]
async fn mounted_clean_text_programmatic_focus_and_tab_remove_canvas_keyboard_ownership() {
for programmatic in [true, false] {
let (mut f, mut mounted) = mount(Layer::Oa).await;
let before = f.app.document.snapshot();
let field = name_field(&mut mounted, &f.app);
if programmatic {
mounted.operate(&f.app, &mut FocusField(field));
} else {
let messages = mounted
.batch(
&mut f.app,
&[
key(keyboard::key::Named::Tab),
key(keyboard::key::Named::Delete),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(
!messages
.iter()
.any(|message| matches!(message, Message::DiagramRemove(_))),
"Tab retires canvas admission before its queued focus operation runs"
);
}
let geometry = mounted.geometry(&f.app);
assert!(
geometry
.focused
.iter()
.any(|(_, bounds, focused)| *bounds == field && *focused),
"clean Inspector receives real focus"
);
assert!(!f.app.draft_dirty());
assert!(
delete(&mut mounted, &mut f.app, true)
.await
.iter()
.all(|message| !matches!(message, Message::DiagramRemove(_)))
);
assert!(!f.app.delete_pending);
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Home)],
mouse::Cursor::Unavailable,
)
.await;
let messages = delete(&mut mounted, &mut f.app, false).await;
assert!(
messages
.iter()
.any(|message| matches!(message, Message::Field(path, _) if path == "/name"))
);
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "arget");
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(f.app.project().contains(f.node));
assert!(!f.app.delete_pending);
}
}
#[tokio::test]
async fn mounted_removal_intents_reject_stale_selection_revision_owner_and_document() {
for boundary in 0..5 {
let (mut f, mut mounted) = mount(Layer::Oa).await;
let messages = mounted.input(
&f.app,
&[key(keyboard::key::Named::Delete)],
mouse::Cursor::Unavailable,
);
let intent = messages
.into_iter()
.find(|message| matches!(message, Message::DiagramRemove(_)))
.unwrap();
match boundary {
0 => {
let _ = f.app.update(Message::Select(f.other));
}
1 => {
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
}
2 => {
f.app.active_diagram = Some(f.copy);
}
3 => {
f.app.canvas_document = Id::new_v4();
}
_ => {
let _ = f
.app
.update(Message::Command(Command::Rename(f.other, "Renamed".into())));
}
}
let before = f.app.document.snapshot();
let task = f.app.update(intent);
mounted.perform(&mut f.app, task).await;
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(f.app.project().contains(f.node));
assert!(!f.app.delete_pending);
}
}
#[tokio::test]
async fn mounted_window_modal_dirty_and_modifier_boundaries_retire_delete_focus() {
for boundary in 0..5 {
let (mut f, mut mounted) = mount(Layer::Oa).await;
let before = f.app.document.snapshot();
match boundary {
0 => {
mounted
.batch(
&mut f.app,
&[Input::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
}
1 => {
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Open(
crate::model_menu::Request::object(f.object, Point::ORIGIN),
)));
mounted.settle(&mut f.app).await;
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
}
2 => {
let _ = f.app.update(Message::Field("/name".into(), "Dirty".into()));
}
3 => {
let _ = f.app.update(Message::Delete);
}
_ => {
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
}
}
delete(&mut mounted, &mut f.app, false).await;
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(f.app.project().contains(f.node));
}
let (mut f, mut mounted) = mount(Layer::Oa).await;
let before = f.app.document.snapshot();
mounted
.batch(
&mut f.app,
&[modified(
keyboard::key::Named::Delete,
keyboard::Modifiers::CTRL,
)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.delete_pending);
// Refusal is temporary: a fresh live canvas click owns Delete again.
select(&mut mounted, &mut f).await;
delete(&mut mounted, &mut f.app, false).await;
assert!(!f.app.project().contains(f.node));
}
#[tokio::test]
async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed() {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 0., 0.);
let b = p.add(Kind::Activity, "Receive", 650., 0.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
let p = execute_batch(
&p,
&[Command::SetEdgeRoute {
diagram,
route: EdgeRoute {
edge,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
}],
)
.unwrap();
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.active_diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
let _ = app.update(Message::Select(edge));
let mut mounted = Mounted::new(&mut app).await;
app.zoom = 1.;
app.pan = Vector::new(100., 250.);
app.fit_to_view = false;
mounted.settle(&mut app).await;
let bounds = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let pin = Point::new(bounds.x + 500., bounds.y + 150.);
mounted.click(&mut app, pin).await;
assert_eq!(
app.selected_bend,
Some((edge, 0)),
"real pointer selects route pin"
);
let before = app.document.snapshot();
let messages = delete(&mut mounted, &mut app, false).await;
assert!(messages.iter().any(|message| matches!(
message,
Message::Route(crate::route_edit::Event::RemoveBend)
)));
assert!(
!messages
.iter()
.any(|message| matches!(message, Message::DiagramRemove(_)))
);
assert!(app.project().contains(edge) && app.project().contains(a) && app.project().contains(b));
assert!(
crate::route_edit::stored_route(&app.project().diagrams[&diagram], edge)
.waypoints
.is_empty()
);
assert!(!app.delete_pending);
consistent(&app);
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.document.bundle(), &before.bundle);
// Select a real edge segment away from its handle, then Delete opens the
// same semantic confirmation instead of inventing an edge hide operation.
let view = projection::project_view(app.project(), diagram);
let routed = routing::route_diagram(app.project(), &app.project().diagrams[&diagram], &view);
let route = &routed.edges[&edge];
let segment = route
.points
.windows(2)
.max_by(|a, b| {
let len = |s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
len(a).total_cmp(&len(b))
})
.unwrap();
let at = Point::new(
bounds.x + (segment[0].x + segment[1].x) / 2. + app.pan.x,
bounds.y + (segment[0].y + segment[1].y) / 2. + app.pan.y,
);
mounted.click(&mut app, at).await;
assert_eq!(app.selected, edge);
assert!(app.selected_bend.is_none());
delete(&mut mounted, &mut app, false).await;
assert_eq!(
app.delete_target.as_ref().unwrap().snapshot.request.object,
edge
);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[tokio::test]
async fn mounted_state_delete_uses_state_placement_and_preserves_architecture_placement() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
.unwrap();
let architecture = p.ensure_diagram(Layer::Sa);
let architecture_node = p.place(architecture, state, Position { x: 50., y: 50. });
let plan = crate::behavior::state_edit::create_view(&p, machine).unwrap();
let diagram = plan.diagram;
let p = execute_batch(&p, &[plan.command]).unwrap();
let node = p.diagrams[&diagram]
.nodes
.values()
.find(|node| node.element == state)
.unwrap()
.id;
let mut app = App::with_project(p);
app.layer = Layer::Sa;
app.page = Page::Behavior;
app.active_diagram = Some(architecture);
app.behavior.active_owner = Some(machine);
app.behavior.diagram.diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
let _ = app.update(Message::Select(state));
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted
.geometry(&app)
.focused
.into_iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.unwrap()
.1;
let projected = projection::project_view(app.project(), diagram)
.nodes
.into_iter()
.find(|n| n.id == node)
.unwrap();
let at = Point::new(
bounds.x
+ (projected.position.x + 35.) * app.behavior.diagram.zoom
+ app.behavior.diagram.pan.x,
bounds.y
+ (projected.position.y + 35.) * app.behavior.diagram.zoom
+ app.behavior.diagram.pan.y,
);
mounted.click(&mut app, at).await;
assert_eq!(app.selected, state);
assert_eq!(app.behavior.diagram.placement, Some(node));
let before = app.document.snapshot();
let messages = delete(&mut mounted, &mut app, false).await;
assert!(messages.iter().any(|message| matches!(
message,
Message::StateDiagram(crate::behavior::state_edit::Event::Remove(_))
)));
assert!(!app.project().contains(node));
assert!(app.project().contains(architecture_node) && app.project().contains(state));
consistent(&app);
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.document.bundle(), &before.bundle);
}
#[tokio::test]
async fn captured_confirmation_rejects_selection_document_revision_and_placement_changes() {
for boundary in 0..5 {
let (mut f, mut mounted) = mount(Layer::Oa).await;
delete(&mut mounted, &mut f.app, true).await;
let token = f.app.delete_target.as_ref().unwrap().snapshot.token;
// Model asynchronous adoption / retained stale UI using a changed App
// state while preserving the old confirmation payload deliberately.
match boundary {
0 => {
f.app.selected = f.other;
}
1 => {
f.app.canvas_document = Id::new_v4();
}
2 => {
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
}
3 => {
f.app.selected_node = None;
}
_ => {
assert!(
f.app
.document
.execute(Command::Rename(f.other, "New revision".into()))
.is_ok()
);
}
}
let before = f.app.document.snapshot();
let task = f.app.update(Message::ConfirmDelete(token));
mounted.perform(&mut f.app, task).await;
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(f.app.project().contains(f.object));
assert!(!f.app.delete_pending);
}
}
#[tokio::test]
async fn mounted_programmatic_canvas_entry_needs_no_mouse_focus_boolean() {
let mut f = fixture(Layer::Oa);
let _ = f.app.update(Message::Select(f.object));
let mut mounted = Mounted::new(&mut f.app).await;
let task =
iced::widget::operation::focus(iced::advanced::widget::Id::from("diagram-keyboard-owner"));
mounted.perform(&mut f.app, task).await;
let messages = delete(&mut mounted, &mut f.app, false).await;
assert!(
messages
.iter()
.any(|message| matches!(message, Message::DiagramRemove(_)))
);
assert!(!f.app.project().contains(f.node));
assert!(f.app.project().contains(f.object));
}
#[tokio::test]
async fn mounted_allocation_work_click_then_delete_without_redraw_removes_placement_once() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let performer = p
.add_element(
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
"Performer",
None,
)
.unwrap();
let work = p
.add_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
"Work",
None,
)
.unwrap();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
p.place(diagram, performer, Position { x: 100., y: 100. });
let node = p.place(diagram, work, Position { x: 100., y: 500. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = layer;
app.active_diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
let mut mounted = Mounted::new(&mut app).await;
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
mounted.settle(&mut app).await;
let bounds = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let point = Point::new(bounds.x + 140., bounds.y + 535.);
let messages = mounted.click(&mut app, point).await;
assert!(messages.iter().any(|message| matches!(
message,
Message::WorkDrag(crate::app::allocation_drag::Event::Press(_))
)));
assert_eq!(app.selected, work);
assert_eq!(app.selected_node, Some(node));
let before = app.document.snapshot();
// Deliberately do not settle/redraw between completed click and key.
let messages = delete(&mut mounted, &mut app, false).await;
assert!(
messages
.iter()
.any(|message| matches!(message, Message::DiagramRemove(_))),
"retired allocation press cannot consume first Delete"
);
assert!(!app.project().contains(node));
assert!(app.project().contains(work) && app.project().contains(performer));
consistent(&app);
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
}
+35 -79
View File
@@ -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();
@@ -114,7 +114,6 @@ impl App {
}
}
match event {
Event::Remove(intent) => self.remove_from_diagram(intent),
Event::Viewport(_, size) => {
if size.width.is_finite()
&& size.height.is_finite()
+224 -319
View File
@@ -14,16 +14,8 @@ 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;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
pub(crate) mod state_menu;
@@ -41,7 +33,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> {
@@ -49,10 +40,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,
@@ -323,7 +332,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))
@@ -393,96 +402,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]
@@ -564,9 +574,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);
@@ -583,91 +593,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());
}
}
}
@@ -728,29 +739,6 @@ pub enum Event {
Submit,
}
impl Event {
pub fn is_source_back(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
)))
)
}
pub fn is_source(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Source(_))
| Self::Scenario(scenario::Event::Source(_))
| Self::Topology(topology::Event::Source(_))
)
}
pub fn starts_form(&self) -> bool {
matches!(
self,
@@ -881,18 +869,6 @@ impl State {
/// Clear the active form after its prepared command commits successfully.
/// Rejected commands must leave the form available for repair and retry.
pub fn source_active(&self) -> bool {
self.creation.source_active()
|| self.scenario.source_active()
|| self.topology.source_active()
}
pub fn source_open(&self) -> bool {
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
}
pub fn source_back(&mut self) -> bool {
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
}
pub fn acknowledge_commit(&mut self) {
self.creation.acknowledge_commit();
self.topology.acknowledge_commit();
@@ -940,40 +916,6 @@ pub fn view(app: &App) -> Element<'_, Message> {
if let Some(card) = app.behavior.creation.view(p) {
return card.map(|event| Message::Behavior(Event::Creation(event)));
}
if let Some(owner) = app.behavior.pending_owner() {
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
let unavailable = p.layer_of(owner) != Some(app.layer)
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|| (app.behavior.topology.pending_owner().is_some()
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
if unavailable {
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
if let Some(name) = app
.behavior
.scenario
.pending_name()
.or_else(|| app.behavior.topology.pending_name())
{
out =
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
}
if let Some(child) = app.behavior.scenario.source_view(p) {
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
} else if let Some(child) = app.behavior.topology.source_view(p) {
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
} else {
let cancel = if app.behavior.scenario.pending_owner().is_some() {
Event::Scenario(scenario::Event::Cancel)
} else if app.behavior.topology.pending_owner().is_some() {
Event::Topology(topology::Event::Cancel)
} else {
Event::States(states::Event::Cancel)
};
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
}
return container(out).padding(16).width(Fill).height(Fill).into();
}
}
let choices = p
.elements
.values()
@@ -1139,7 +1081,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>,
@@ -1193,7 +1135,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.),
@@ -1804,7 +1746,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!(
@@ -1827,7 +1770,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(_))
@@ -1839,9 +1782,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 {
@@ -1853,12 +1798,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 {
@@ -1878,14 +1820,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 {
@@ -1895,19 +1837,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());
@@ -1981,18 +1915,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,
@@ -2030,7 +1952,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);
for b in &self.boxes {
let outline = matches!(
@@ -2142,12 +2063,9 @@ impl canvas::Program<Message> for Scene {
}
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 {
@@ -2192,18 +2110,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(
@@ -2409,26 +2315,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)
}
}
File diff suppressed because it is too large Load Diff
+5 -101
View File
@@ -75,7 +75,6 @@ impl Picker {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Open(Layer),
Choose(Family),
Name(String),
@@ -98,11 +97,6 @@ struct Browser {
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
version: u32,
document: Id,
layer: Layer,
@@ -131,17 +125,6 @@ pub struct Created {
}
impl State {
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub fn has_pending(&self) -> bool {
self.draft.is_some()
}
@@ -191,49 +174,6 @@ impl State {
.command(p)
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.family != Some(Family::Machine) {
return Err("This reference does not support staged source creation.".into());
}
if d.document != p.id {
return Err("This behavior draft belongs to another document.".into());
}
let mut next = d.clone();
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Machine { layer: d.layer },
d.invocation,
event,
)? {
next.owner = Some(id);
let preview = next
.source
.preview(p, next.owner)?
.ok_or("New owner preview is missing.")?;
if !eligible(&preview, next.layer, Picker::Owner, id) {
return Err("This participant cannot own the machine.".into());
}
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
match event {
Event::Cancel => {
self.draft = None;
@@ -244,8 +184,6 @@ impl State {
return Err("Create or cancel the pending behavior first.".into());
}
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
version: 1,
document: p.id,
layer,
@@ -394,7 +332,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
};
format!("{} · {} · {location}", e.kind(), e.alias)
}
pub(super) fn reference_label(p: &Project, id: Id) -> String {
fn reference_label(p: &Project, id: Id) -> String {
p.elements
.get(&id)
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
@@ -402,15 +340,6 @@ pub(super) fn reference_label(p: &Project, id: Id) -> String {
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
self.source.check_scope(
super::source_draft::Scope::Machine { layer: self.layer },
self.owner,
)?;
let preview = self.source.preview(p, self.owner)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.owner, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id {
return Err("This draft belongs to another document.".into());
}
@@ -546,21 +475,6 @@ impl State {
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.draft.as_ref()?;
if let Some(view) = d.source.view(p) {
return Some(
column![
text(format!(
"State machine name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
view.map(Event::Source)
]
.spacing(6)
.height(Fill)
.into(),
);
}
let mut fields = column![].spacing(10);
let title = if let Some(family) = d.family {
fields = fields.push(
@@ -574,7 +488,7 @@ impl State {
Family::Sequence => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Add work and exchange contributions after creation.",
"Owned by the {} layer root. Involve existing work after creation.",
d.layer
))
.size(12),
@@ -617,17 +531,7 @@ impl State {
}
}
Family::Machine => {
if let Some(label) = d.source.label(d.owner) {
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
}
fields = fields.push(picker_view(p, d, Picker::Owner));
fields = fields.push(
button("Create new owner…")
.on_press(Event::Source(super::source_draft::Event::Open(
d.invocation,
)))
.padding(7),
);
fields = fields.push(
checkbox(d.first_view)
.label("Create the first state view")
@@ -640,9 +544,9 @@ impl State {
} else {
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let purpose = match family {
Family::Sequence => "A path through work and exchanges",
Family::Sequence => "A path through existing work and exchanges",
Family::Scenario => "Ordered interactions from an explicit viewpoint",
Family::Machine => "States and modes of an explicitly chosen participant",
Family::Machine => "States and modes of an existing participant",
};
fields = fields.push(
button(
@@ -712,7 +616,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
}
@@ -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,
)
}
}
+85 -301
View File
@@ -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)]
@@ -99,9 +97,7 @@ pub enum CanvasEvent {
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,7 +125,8 @@ 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 {
@@ -138,9 +135,7 @@ impl CanvasEvent {
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, _)
@@ -167,7 +162,6 @@ impl std::fmt::Display for Operator {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Start(Id, Tool),
Cancel,
Submit,
@@ -203,14 +197,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 +211,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 {
@@ -248,13 +240,6 @@ struct CallChoiceContext {
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
#[serde(default)]
source_origin: Option<(Id, oa_core::Element)>,
owner: Id,
parent: Id,
before: Option<Id>,
@@ -287,44 +272,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())
}
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
self.draft
.as_ref()?
.source
.view(p)
.map(|v| v.map(Event::Source))
}
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
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
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
@@ -332,38 +279,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 +317,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 +348,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 +385,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 +413,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,
@@ -542,16 +452,8 @@ impl State {
}
cached.as_ref().unwrap().1.clone()
}
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let draft = self.draft.as_ref()?;
if self
.draft
.as_ref()
.is_some_and(|d| d.source.label(d.target).is_some())
&& self.prepare_pending(p).ok().as_ref() != Some(command)
{
return None;
}
fn created(command: &Command, parent: Id) -> Option<Id> {
match command {
Command::CreateElement(element) if element.parent == Some(parent) => {
@@ -666,66 +568,6 @@ impl State {
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
self.remount_form();
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.tool != Tool::Lifeline {
return Err("This reference does not support staged source creation.".into());
}
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
*document != p.id || p.elements.get(&d.owner) != Some(owner)
}) {
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
}
if d.source_origin.is_none() && d.source.engaged() {
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
}
let mut next = d.clone();
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
{
next.source_origin = Some((
p.id,
p.elements
.get(&d.owner)
.ok_or("The consumer owner no longer exists.")?
.clone(),
));
}
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Lifeline {
owner: d.owner,
viewpoint: viewpoint(p, d.owner),
},
d.invocation,
event,
)? {
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) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
@@ -734,13 +576,7 @@ 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(),
source_origin: Some((p.id, p.elements[&owner].clone())),
owner,
parent: owner,
before: None,
@@ -1104,14 +940,7 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
}
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
let Some(owner) = p
.elements
.get(&d.owner)
.filter(|e| e.kind() == Kind::Scenario)
else {
return vec![];
};
let layer = owner.layer();
let layer = p.elements[&d.owner].layer();
let mut values: Vec<_> = p
.elements
.values()
@@ -1427,28 +1256,6 @@ impl Draft {
e
}
fn command(&self, p: &Project) -> Result<Command, String> {
if self.source.engaged()
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
*document != p.id || p.elements.get(&self.owner) != Some(owner)
})
{
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
}
self.source.check_scope(
super::source_draft::Scope::Lifeline {
owner: self.owner,
viewpoint: viewpoint(p, self.owner),
},
self.target,
)?;
let preview = self.source.preview(p, self.target)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.target, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
}
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
return Err("Choose this scenario or one of its operands as destination.".into());
}
@@ -1459,8 +1266,6 @@ impl Draft {
.unwrap_or(self.tool.label());
let name = if self.name.trim().is_empty() {
default
} else if self.source.label(self.target).is_some() {
self.name.as_str()
} else {
self.name.trim()
};
@@ -2052,31 +1857,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 +1881,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 +1892,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 +2074,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 +2265,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 +2459,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 +2489,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 +2517,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 +2536,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;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,283 +0,0 @@
//! Read-only lifecycle geometry. Operand screen order is never execution order.
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>,
pub scopes: &'a BTreeMap<Id, (f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Span {
pub start: f32,
pub end: f32,
}
impl Span {
fn valid(self) -> bool {
self.start.is_finite() && self.end.is_finite() && self.start <= self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Life {
Alive,
Absent,
Unresolved,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Interval {
pub scope: Id,
pub span: Span,
pub life: Life,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MarkerKind {
Birth,
Death,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Marker {
pub occurrence: Id,
pub scope: Id,
pub y: f32,
pub kind: MarkerKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UncertaintyReason {
Conditional,
Repeated,
Parallel,
MissingGeometry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Uncertainty {
pub scope: Id,
pub reason: UncertaintyReason,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LaneLifetime {
pub lifeline: Id,
pub intervals: Vec<Interval>,
pub markers: Vec<Marker>,
pub uncertainties: Vec<Uncertainty>,
}
/// Stable lane-ID order; source/model/history and caller-owned geometry are untouched.
/// An opaque ScenarioUse is a local use, not a caller birth/death contract.
pub(crate) fn project_lifetimes(
project: &Project,
scenario: Id,
geometry: &Geometry<'_>,
) -> Vec<LaneLifetime> {
if project
.elements
.get(&scenario)
.is_none_or(|e| e.kind() != Kind::Scenario)
{
return vec![];
}
project
.elements
.values()
.filter(|e| e.kind() == Kind::Lifeline && e.parent == Some(scenario))
.map(|e| {
let mut projection = Projection {
project,
geometry,
lane: e.id,
visiting: BTreeSet::new(),
result: LaneLifetime {
lifeline: e.id,
intervals: vec![],
markers: vec![],
uncertainties: vec![],
},
};
let initial = if projection.contains(scenario, Some(MarkerKind::Birth)) {
Life::Unresolved
} else {
Life::Alive
};
projection.sequence(scenario, initial);
projection.result
})
.collect()
}
struct Projection<'a, 'g> {
project: &'a Project,
geometry: &'a Geometry<'g>,
lane: Id,
visiting: BTreeSet<Id>,
result: LaneLifetime,
}
impl Projection<'_, '_> {
fn body(&self, id: Id) -> Option<&OccurrenceBody> {
match self.project.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. } => Some(body),
_ => None,
}
}
fn event(&self, id: Id) -> Option<MarkerKind> {
match self.body(id)? {
OccurrenceBody::Exchange {
receiver: Some(line),
message_kind,
..
} if *line == self.lane => match message_kind {
MessageKind::Create => Some(MarkerKind::Birth),
MessageKind::Delete => Some(MarkerKind::Death),
_ => None,
},
_ => None,
}
}
fn contains(&self, scope: Id, kind: Option<MarkerKind>) -> bool {
// Local structural walk only: no guards, iteration simulation or reuse expansion.
let mut pending = vec![scope];
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if self
.event(id)
.is_some_and(|event| kind.is_none_or(|k| event == k))
{
return true;
}
if let Some(steps) = self.project.elements.get(&id).and_then(|e| e.steps()) {
pending.extend(steps);
}
if let Some(OccurrenceBody::Fragment { branches, .. }) = self.body(id) {
pending.extend(branches);
}
}
false
}
fn span(&self, scope: Id) -> Option<Span> {
let &(start, end) = self.geometry.scopes.get(&scope)?;
let span = Span { start, end };
span.valid().then_some(span)
}
fn uncertain(&mut self, scope: Id, reason: UncertaintyReason) {
let value = Uncertainty { scope, reason };
if !self.result.uncertainties.contains(&value) {
self.result.uncertainties.push(value);
}
}
fn interval(&mut self, scope: Id, start: f32, end: f32, life: Life) {
if start < end && start.is_finite() && end.is_finite() {
self.result.intervals.push(Interval {
scope,
span: Span { start, end },
life,
});
}
}
fn sequence(&mut self, scope: Id, entry: Life) -> Life {
let Some(span) = self.span(scope) else {
self.uncertain(scope, UncertaintyReason::MissingGeometry);
return Life::Unresolved;
};
if !self.visiting.insert(scope) {
self.uncertain(scope, UncertaintyReason::MissingGeometry);
return Life::Unresolved;
}
let steps = self
.project
.elements
.get(&scope)
.and_then(|e| e.steps())
.cloned()
.unwrap_or_default();
// A creation directly in this sequence fixes its local birth boundary.
// A creation only in a nested operand cannot fix its ancestor's entry.
let mut life = if steps
.iter()
.any(|&id| self.event(id) == Some(MarkerKind::Birth))
{
Life::Absent
} else {
entry
};
let mut cursor = span.start;
for id in steps {
let event = self.event(id);
let fragment = match self.body(id) {
Some(OccurrenceBody::Fragment { operator, branches }) => {
Some((*operator, branches.clone()))
}
_ => None,
};
if event.is_none() && fragment.is_none() {
continue;
}
let y = self
.geometry
.rows
.get(&id)
.filter(|(owner, y)| {
*owner == scope && y.is_finite() && *y >= cursor && *y <= span.end
})
.map(|(_, y)| *y);
let Some(y) = y else {
self.uncertain(id, UncertaintyReason::MissingGeometry);
life = Life::Unresolved;
continue;
};
self.interval(scope, cursor, y, life);
cursor = y;
if let Some(kind) = event {
self.result.markers.push(Marker {
occurrence: id,
scope,
y,
kind,
});
life = match kind {
MarkerKind::Birth => Life::Alive,
MarkerKind::Death => Life::Absent,
};
}
if let Some((operator, branches)) = fragment {
let entry = life;
let mut missing = false;
for branch in branches {
let bounds = self
.span(branch)
.filter(|s| s.start >= cursor && s.end <= span.end);
if let Some(bounds) = bounds {
// Every sibling inherits the same entry, never its displayed predecessor's exit.
self.sequence(branch, entry);
cursor = bounds.end;
} else {
self.uncertain(branch, UncertaintyReason::MissingGeometry);
missing = true;
}
}
if self.contains(id, None) {
let reason = match operator {
FragmentOperator::Alternative | FragmentOperator::Optional => {
UncertaintyReason::Conditional
}
FragmentOperator::Loop => UncertaintyReason::Repeated,
FragmentOperator::Parallel => UncertaintyReason::Parallel,
};
self.uncertain(id, reason);
life = Life::Unresolved;
} else if missing {
life = Life::Unresolved;
}
}
}
self.interval(scope, cursor, span.end, life);
self.visiting.remove(&scope);
life
}
}
#[cfg(test)]
#[path = "scenario_lifecycle/tests.rs"]
mod tests;
@@ -1,470 +0,0 @@
use super::*;
use oa_core::*;
struct Fixture {
p: Project,
owner: Id,
a: Id,
b: Id,
exchange: Id,
rows: BTreeMap<Id, (Id, f32)>,
scopes: BTreeMap<Id, (f32, f32)>,
}
impl Fixture {
fn new(layer: Layer) -> Self {
let mut p = Project::empty();
let kind = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let a = p.add_element(kind, layer, "Request", None).unwrap();
let b = p.add_element(kind, layer, "Provide", None).unwrap();
let owner = p
.add_element(Kind::Scenario, layer, "Lifecycle", None)
.unwrap();
let exchange = p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Service",
a,
b,
);
let line = |p: &mut Project, represented| {
let id = p
.add_element(Kind::Lifeline, layer, "Participant", Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
id
};
let a = line(&mut p, a);
let b = line(&mut p, b);
Self {
p,
owner,
a,
b,
exchange,
rows: BTreeMap::new(),
scopes: [(owner, (0., 600.))].into(),
}
}
fn occurrence(&mut self, owner: Id, y: f32, body: OccurrenceBody) -> Id {
let mut e = self
.p
.make_element(
Kind::Occurrence,
self.p.elements[&owner].layer(),
"Occurrence",
Some(owner),
)
.unwrap();
let id = e.id;
*e.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
self.p.elements.insert(id, e);
if let Some(steps) = self.p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
} else if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = self.p.elements.get_mut(&owner).unwrap().shared_mut()
{
branches.push(id);
}
self.rows.insert(id, (owner, y));
id
}
fn message(&mut self, owner: Id, y: f32, kind: MessageKind) -> Id {
self.occurrence(
owner,
y,
OccurrenceBody::Exchange {
exchange: Some(self.exchange),
sender: Some(self.a),
receiver: Some(self.b),
message_kind: kind,
operation: None,
reply_to: None,
values: vec![],
},
)
}
fn fragment(&mut self, owner: Id, y: f32, operator: FragmentOperator) -> Id {
self.occurrence(
owner,
y,
OccurrenceBody::Fragment {
operator,
branches: vec![],
},
)
}
fn branch(&mut self, fragment: Id, start: f32, end: f32) -> Id {
let id = self.occurrence(
fragment,
start,
OccurrenceBody::Branch {
guard: "condition".into(),
occurrences: vec![],
},
);
self.scopes.insert(id, (start, end));
id
}
fn project(&self) -> Vec<LaneLifetime> {
let errors: Vec<_> = validation::validate(&self.p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(
errors.is_empty(),
"fixture must be ordinarily admitted: {errors:?}"
);
let before = self.p.clone();
let source = source_project::format_bundle(&self.p);
let geometry = Geometry {
rows: &self.rows,
scopes: &self.scopes,
};
let result = project_lifetimes(&self.p, self.owner, &geometry);
assert_eq!(result, project_lifetimes(&self.p, self.owner, &geometry));
assert_eq!(self.p, before);
assert_eq!(source_project::format_bundle(&self.p), source);
assert!(
result
.windows(2)
.all(|pair| pair[0].lifeline < pair[1].lifeline)
);
result
}
fn lane(&self) -> LaneLifetime {
self.project()
.into_iter()
.find(|lane| lane.lifeline == self.b)
.unwrap()
}
}
fn intervals(lane: &LaneLifetime, scope: Id) -> Vec<(f32, f32, Life)> {
lane.intervals
.iter()
.filter(|i| i.scope == scope)
.map(|i| (i.span.start, i.span.end, i.life))
.collect()
}
#[test]
fn root_lifetime_has_exact_birth_death_boundaries_without_source_mutation() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let create = f.message(f.owner, 100., MessageKind::Create);
f.message(f.owner, 200., MessageKind::Asynchronous);
let delete = f.message(f.owner, 300., MessageKind::Delete);
let lane = f.lane();
assert_eq!(
intervals(&lane, f.owner),
vec![
(0., 100., Life::Absent),
(100., 300., Life::Alive),
(300., 600., Life::Absent)
]
);
assert_eq!(
lane.markers,
vec![
Marker {
occurrence: create,
scope: f.owner,
y: 100.,
kind: MarkerKind::Birth
},
Marker {
occurrence: delete,
scope: f.owner,
y: 300.,
kind: MarkerKind::Death
},
]
);
let sender = f
.project()
.into_iter()
.find(|lane| lane.lifeline == f.a)
.unwrap();
assert_eq!(intervals(&sender, f.owner), vec![(0., 600., Life::Alive)]);
}
}
#[test]
fn alternative_death_does_not_kill_its_displayed_sibling() {
let mut f = Fixture::new(Layer::Sa);
let fragment = f.fragment(f.owner, 100., FragmentOperator::Alternative);
let left = f.branch(fragment, 120., 250.);
let right = f.branch(fragment, 300., 450.);
f.message(left, 200., MessageKind::Delete);
f.message(right, 350., MessageKind::Asynchronous);
let lane = f.lane();
assert_eq!(
intervals(&lane, left),
vec![(120., 200., Life::Alive), (200., 250., Life::Absent)]
);
assert_eq!(intervals(&lane, right), vec![(300., 450., Life::Alive)]);
assert_eq!(
intervals(&lane, f.owner),
vec![(0., 100., Life::Alive), (450., 600., Life::Unresolved)]
);
assert_eq!(
lane.uncertainties,
vec![Uncertainty {
scope: fragment,
reason: UncertaintyReason::Conditional
}]
);
}
#[test]
fn conditional_birth_does_not_infer_absence_in_siblings_or_ancestors() {
for operator in [FragmentOperator::Alternative, FragmentOperator::Optional] {
let mut f = Fixture::new(Layer::Oa);
let fragment = f.fragment(f.owner, 100., operator);
let left = f.branch(fragment, 120., 250.);
let right = f.branch(fragment, 300., 450.);
f.message(left, 200., MessageKind::Create);
let lane = f.lane();
assert_eq!(
intervals(&lane, left),
vec![(120., 200., Life::Absent), (200., 250., Life::Alive)]
);
assert_eq!(
intervals(&lane, right),
vec![(300., 450., Life::Unresolved)]
);
assert_eq!(
intervals(&lane, f.owner),
vec![(0., 100., Life::Unresolved), (450., 600., Life::Unresolved)]
);
}
}
#[test]
fn loop_lifetime_is_local_and_warning_bearing_loops_remain_drawable() {
for balanced in [true, false] {
let mut f = Fixture::new(Layer::Sa);
let fragment = f.fragment(f.owner, 100., FragmentOperator::Loop);
let iteration = f.branch(fragment, 120., 450.);
let create = f.message(iteration, 160., MessageKind::Create);
f.message(iteration, 250., MessageKind::Asynchronous);
if balanced {
f.message(iteration, 350., MessageKind::Delete);
}
let lane = f.lane();
assert_eq!(lane.markers.len(), if balanced { 2 } else { 1 });
assert_eq!(lane.markers[0].occurrence, create);
assert!(lane.uncertainties.contains(&Uncertainty {
scope: fragment,
reason: UncertaintyReason::Repeated
}));
assert_eq!(
intervals(&lane, f.owner).last().unwrap().2,
Life::Unresolved
);
assert_eq!(
validation::validate(&f.p)
.iter()
.any(|i| i.code == "lifeline_repetition"),
!balanced
);
}
}
#[test]
fn optional_cleanup_inside_loop_does_not_claim_balanced_or_definite_exit() {
let mut f = Fixture::new(Layer::Sa);
let repeated = f.fragment(f.owner, 100., FragmentOperator::Loop);
let iteration = f.branch(repeated, 120., 500.);
f.message(iteration, 160., MessageKind::Create);
let optional = f.fragment(iteration, 280., FragmentOperator::Optional);
let cleanup = f.branch(optional, 320., 400.);
f.message(cleanup, 350., MessageKind::Delete);
let lane = f.lane();
assert_eq!(
intervals(&lane, cleanup),
vec![(320., 350., Life::Alive), (350., 400., Life::Absent)]
);
assert_eq!(
intervals(&lane, iteration),
vec![
(120., 160., Life::Absent),
(160., 280., Life::Alive),
(400., 500., Life::Unresolved)
]
);
assert!(
validation::validate(&f.p)
.iter()
.any(|i| i.code == "lifeline_repetition")
);
assert!(lane.uncertainties.contains(&Uncertainty {
scope: optional,
reason: UncertaintyReason::Conditional
}));
assert!(lane.uncertainties.contains(&Uncertainty {
scope: repeated,
reason: UncertaintyReason::Repeated
}));
}
#[test]
fn lifecycle_free_fragments_do_not_poison_other_lanes() {
let mut f = Fixture::new(Layer::Oa);
let fragment = f.fragment(f.owner, 100., FragmentOperator::Parallel);
let left = f.branch(fragment, 120., 250.);
let right = f.branch(fragment, 300., 450.);
f.message(left, 200., MessageKind::Delete);
let result = f.project();
let sender = result.iter().find(|lane| lane.lifeline == f.a).unwrap();
assert!(sender.intervals.iter().all(|i| i.life == Life::Alive));
assert!(sender.uncertainties.is_empty());
let target = result.iter().find(|lane| lane.lifeline == f.b).unwrap();
assert!(target.uncertainties.contains(&Uncertainty {
scope: fragment,
reason: UncertaintyReason::Parallel
}));
assert_eq!(intervals(target, right), vec![(300., 450., Life::Alive)]);
}
#[test]
fn explicit_receiver_is_authoritative_even_with_missing_exchange() {
let mut f = Fixture::new(Layer::Oa);
let create = f.message(f.owner, 100., MessageKind::Create);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
{
*exchange = None;
}
assert_eq!(f.lane().markers[0].occurrence, create);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
receiver, exchange, ..
},
..
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
{
*receiver = None;
*exchange = Some(f.exchange);
}
assert!(
f.lane().markers.is_empty(),
"exchange endpoints never invent a receiver"
);
}
#[test]
fn missing_geometry_is_reported_without_nonfinite_or_guessed_markers() {
let mut f = Fixture::new(Layer::Oa);
let create = f.message(f.owner, 100., MessageKind::Create);
f.rows.insert(create, (f.owner, f32::NAN));
let lane = f.lane();
assert!(lane.markers.is_empty());
assert_eq!(
lane.uncertainties,
vec![Uncertainty {
scope: create,
reason: UncertaintyReason::MissingGeometry
}]
);
assert_eq!(
intervals(&lane, f.owner),
vec![(0., 600., Life::Unresolved)]
);
f.scopes.remove(&f.owner);
let lane = f.lane();
assert!(lane.intervals.is_empty());
assert_eq!(lane.uncertainties[0].scope, f.owner);
}
#[test]
fn scenario_use_is_opaque_and_never_imports_callee_lifecycle_events() {
let mut f = Fixture::new(Layer::Oa);
let callee =
f.p.add_element(Kind::Scenario, Layer::Oa, "Callee", None)
.unwrap();
let remote_a =
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote A", Some(callee))
.unwrap();
let remote_b =
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote B", Some(callee))
.unwrap();
for (remote, local) in [(remote_a, f.a), (remote_b, f.b)] {
let body = f.p.elements[&local].shared().unwrap().clone();
*f.p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() = body;
}
f.occurrence(
callee,
200.,
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: Some(remote_a),
receiver: Some(remote_b),
message_kind: MessageKind::Create,
operation: None,
reply_to: None,
values: vec![],
},
);
f.occurrence(
f.owner,
100.,
OccurrenceBody::ScenarioUse {
scenario: Some(callee),
bindings: vec![
LifelineBinding {
referenced: remote_a,
local: f.a,
},
LifelineBinding {
referenced: remote_b,
local: f.b,
},
],
},
);
let lane = f.lane();
assert_eq!(intervals(&lane, f.owner), vec![(0., 600., Life::Alive)]);
assert!(lane.markers.is_empty());
assert!(lane.uncertainties.is_empty());
}
#[test]
fn projection_retains_exact_authored_source_and_does_not_enter_history() {
use crate::document::Document;
let mut f = Fixture::new(Layer::Oa);
f.message(f.owner, 100., MessageKind::Create);
f.message(f.owner, 300., MessageKind::Delete);
let mut source = source_project::format_bundle(&f.p);
let text = source.files.get_mut(&source.entry).unwrap();
*text = format!("// Retained author note — 雨\r\n{text}\n");
let document =
Document::from_bundle(source.clone(), source_project::ProjectLimits::default()).unwrap();
let accepted = document.project().clone();
let geometry = Geometry {
rows: &f.rows,
scopes: &f.scopes,
};
let result = project_lifetimes(document.project(), f.owner, &geometry);
assert_eq!(result, f.project());
assert_eq!(document.bundle(), &source);
assert_eq!(document.project(), &accepted);
assert!(!document.can_undo());
assert!(!document.can_redo());
}
@@ -1,488 +0,0 @@
//! A prerequisite belongs to its pending consumer, never the accepted catalog.
use iced::{
Element as UiElement, Fill,
widget::{button, column, container, row, scrollable, text, text_input},
};
use oa_core::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const NAME_INPUT: &str = "behavior-source-name";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Scope {
Lifeline {
owner: Id,
viewpoint: ScenarioViewpoint,
},
Work {
owner: Id,
},
Machine {
layer: Layer,
},
}
impl Scope {
fn owner(self) -> Option<Id> {
match self {
Self::Lifeline { owner, .. } | Self::Work { owner } => Some(owner),
Self::Machine { .. } => None,
}
}
fn layer(self, p: &Project) -> Result<Layer, String> {
match self {
Self::Machine { layer } => Ok(layer),
_ => p
.layer_of(self.owner().unwrap())
.ok_or("The consumer owner no longer exists.".into()),
}
}
fn kinds(self, layer: Layer) -> Vec<Kind> {
match self {
Self::Work { .. } => vec![if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
}],
Self::Lifeline {
viewpoint: ScenarioViewpoint::OperationalActivity,
..
} => vec![Kind::Activity],
Self::Lifeline {
viewpoint: ScenarioViewpoint::Functional,
..
} => vec![Kind::Function],
Self::Lifeline {
viewpoint: ScenarioViewpoint::ParticipantInteraction,
..
} => vec![Kind::Entity, Kind::Actor, Kind::Role],
Self::Lifeline {
viewpoint: ScenarioViewpoint::BoundaryInterface,
..
} => vec![Kind::System, Kind::SystemActor],
Self::Machine { .. } if layer == Layer::Oa => vec![Kind::Entity, Kind::Actor],
Self::Machine { .. } => vec![Kind::System, Kind::SystemActor],
_ => vec![],
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Open(Id),
Edit(Id, Edit),
}
#[derive(Debug, Clone)]
pub enum Edit {
Name(String),
Kind(Kind),
Parent(Option<Id>),
Search(String),
Page(usize),
Back,
Use,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
token: Id,
document: Id,
scope: Scope,
layer: Layer,
owner: Option<Element>,
id: Id,
kind: Option<Kind>,
name: String,
parent: Option<Element>,
search: String,
page: usize,
identities: Vec<Id>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct State {
editing: Option<Draft>,
staged: Option<Draft>,
#[serde(default)]
engaged: bool,
}
impl State {
pub fn check_scope(&self, expected: Scope, selected: Option<Id>) -> Result<(), String> {
if self
.editing
.as_ref()
.or_else(|| self.staged.as_ref().filter(|d| Some(d.id) == selected))
.is_some_and(|d| d.scope != expected)
{
return Err("The recovered source request belongs to a different consumer context. Go Back or cancel this draft.".into());
}
Ok(())
}
pub fn dismiss(&mut self, event: &Event) -> Result<bool, String> {
if let Event::Edit(token, Edit::Back) = event {
if self.editing.as_ref().is_none_or(|d| d.token != *token) {
return Err("This Back action belongs to a previous source form.".into());
}
self.editing = None;
self.engaged = true;
return Ok(true);
}
Ok(false)
}
pub fn engaged(&self) -> bool {
self.engaged || self.editing.is_some() || self.staged.is_some()
}
pub fn is_open(&self) -> bool {
self.editing.is_some()
}
pub fn back(&mut self) -> bool {
self.editing.take().is_some()
}
pub fn label(&self, selected: Option<Id>) -> Option<String> {
self.staged
.as_ref()
.filter(|d| Some(d.id) == selected)
.map(|d| {
format!(
"New · {} · {}",
d.kind.map_or("Choose kind".into(), |k| k.to_string()),
d.name
)
})
}
pub fn update(
&mut self,
p: &Project,
scope: Scope,
invocation: Id,
event: Event,
) -> Result<Option<Id>, String> {
if self.dismiss(&event)? {
return Ok(None);
}
match event {
Event::Open(token) => {
if token != invocation || self.is_open() {
return Err("This source request is stale. Use the current form.".into());
}
let layer = scope.layer(p)?;
let kinds = scope.kinds(layer);
if kinds.is_empty() {
return Err("Create new sources requires a strict scenario viewpoint; use an existing source here.".into());
}
self.engaged = true;
self.editing = Some(Draft {
token: Id::new_v4(),
document: p.id,
scope,
layer,
owner: scope.owner().and_then(|id| p.elements.get(&id).cloned()),
id: Id::new_v4(),
kind: (kinds.len() == 1).then_some(kinds[0]),
name: String::new(),
parent: None,
search: String::new(),
page: 0,
identities: (0..match scope {
Scope::Work { owner } => p.children(owner).count() + 4,
Scope::Lifeline { .. } => 1,
Scope::Machine { .. } => 0,
})
.map(|_| Id::new_v4())
.collect(),
});
Ok(None)
}
Event::Edit(token, edit) => {
let d = self
.editing
.as_mut()
.filter(|d| d.token == token && d.scope == scope && d.document == p.id)
.ok_or("This source response is stale; return to the current form.")?;
match edit {
Edit::Back => {
self.editing = None;
Ok(None)
}
Edit::Use => {
d.command(p)?;
let id = d.id;
self.staged = self.editing.take();
Ok(Some(id))
}
Edit::Name(name) => {
d.name = name;
Ok(None)
}
Edit::Kind(kind) => {
if !scope.kinds(d.layer).contains(&kind) {
return Err("Choose an explicitly supported source kind.".into());
}
d.kind = Some(kind);
d.parent = None;
d.page = 0;
Ok(None)
}
Edit::Parent(parent) => {
let kind = d.kind.ok_or("Choose the source kind first.")?;
if !validation::can_parent(p, kind, d.layer, parent) {
return Err("Choose a valid semantic parent.".into());
}
d.parent = parent.map(|id| p.elements[&id].clone());
Ok(None)
}
Edit::Search(query) => {
d.search = query;
d.page = 0;
Ok(None)
}
Edit::Page(page) => {
d.page = page;
Ok(None)
}
}
}
}
}
pub fn preview(&self, p: &Project, selected: Option<Id>) -> Result<Option<Project>, String> {
if self.is_open() {
return Err("Use this new source or go Back before creating the consumer.".into());
}
self.staged
.as_ref()
.filter(|d| Some(d.id) == selected)
.map(|d| execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string()))
.transpose()
}
pub fn combine(
&self,
p: &Project,
selected: Option<Id>,
command: Command,
) -> Result<Command, String> {
let Some(d) = self.staged.as_ref().filter(|d| Some(d.id) == selected) else {
return Ok(command);
};
let preview = execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string())?;
let mut commands = match command {
Command::Batch(commands) => commands,
other => vec![other],
};
let fresh: Vec<_> = commands
.iter()
.filter(|_| !matches!(d.scope, Scope::Machine { .. }))
.filter_map(|c| match c {
Command::CreateElement(e) => Some(e.id),
Command::CreateRelation(r) => Some(r.id),
_ => None,
})
.collect();
if fresh.len() > d.identities.len() {
return Err(
"The path grew beyond this draft. Return and create the source again.".into(),
);
}
let replacements: BTreeMap<_, _> = fresh
.into_iter()
.zip(d.identities.iter().copied())
.collect();
if replacements.values().any(|id| preview.contains(*id)) {
return Err(
"A reserved consumer identity is already used. Start a fresh source draft.".into(),
);
}
let remap = |id: &mut Id| {
if let Some(new) = replacements.get(id) {
*id = *new;
}
};
for c in &mut commands {
match c {
Command::CreateElement(e) => {
remap(&mut e.id);
references::visit_element_refs(e, remap);
e.alias = preview.fresh_alias(&e.name, "contribution", e.id);
}
Command::CreateRelation(r) => {
remap(&mut r.id);
references::visit_relation_refs(r, remap);
r.alias = preview.fresh_alias(&r.name, "path", r.id);
}
Command::EditRelation(_, r) => references::visit_relation_refs(r, remap),
Command::SetSteps(_, steps) => steps.iter_mut().for_each(remap),
// Machine commands already carry their reserved identities.
Command::CreateDiagram(_) | Command::Layout(_) => {}
_ => return Err("Unsupported prerequisite consumer operation.".into()),
}
}
commands.insert(0, d.command(p)?);
let command = super::topology::unique_batch(p, commands);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub fn view<'a, R: iced::advanced::text::Renderer + 'a>(
&'a self,
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.editing.as_ref()?;
let event = move |edit| Event::Edit(d.token, edit);
let mut fields = column![
text(format!("New source · {}", d.layer)).size(18),
text(match d.scope {
Scope::Lifeline { owner, viewpoint } =>
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint),
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
Scope::Machine { .. } => "Owner for the pending state machine".into(),
})
.size(12),
text("The parent form is retained. This source is created only with its consumer.")
.size(12)
]
.spacing(8);
for kind in d.scope.kinds(d.layer) {
fields = fields.push(
button(text(kind.to_string()))
.on_press(event(Edit::Kind(kind)))
.style(if d.kind == Some(kind) {
button::primary
} else {
button::secondary
}),
);
}
fields = fields.push(
text_input("Source name (independent of the consumer name)", &d.name)
.id(NAME_INPUT)
.on_input(move |v| event(Edit::Name(v)))
.padding(8),
);
fields = fields.push(
text(format!(
"Semantic parent: {}",
d.parent
.as_ref()
.map_or(format!("{} layer root", d.layer), |e| format!(
"{} · {} · @{}",
e.name,
e.kind(),
e.alias
))
))
.size(12),
);
if let Some(kind) = d.kind {
fields = fields.push(button("Use layer root").on_press_maybe(
validation::can_parent(p, kind, d.layer, None).then(|| event(Edit::Parent(None))),
));
fields = fields.push(
text_input("Find a semantic parent (optional)", &d.search)
.on_input(move |v| event(Edit::Search(v)))
.padding(6),
);
let query = d.search.to_lowercase();
let mut parents: Vec<_> = p
.elements
.values()
.filter(|e| validation::can_parent(p, kind, d.layer, Some(e.id)))
.map(|e| (e.id, super::creation::reference_label(p, e.id)))
.filter(|(_, label)| label.to_lowercase().contains(&query))
.collect();
parents.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
let page = d.page.min(parents.len().saturating_sub(1) / 6);
for (id, label) in parents.iter().skip(page * 6).take(6) {
fields = fields.push(
button(text(label.clone()).size(12))
.on_press(event(Edit::Parent(Some(*id))))
.width(Fill),
);
}
if parents.len() > 6 {
fields = fields.push(
row![
button("Previous").on_press_maybe(
(page > 0).then(|| event(Edit::Page(page.saturating_sub(1))))
),
text(format!("{} / {}", page + 1, parents.len().div_ceil(6))),
button("Next").on_press_maybe(
((page + 1) * 6 < parents.len()).then(|| event(Edit::Page(page + 1)))
)
]
.spacing(6),
);
}
}
let ready = d.command(p);
if let Err(error) = &ready {
fields = fields.push(text(error.clone()).size(12));
}
Some(
container(
column![
scrollable(fields).id("behavior-source-fields").height(Fill),
container(
row![
button("Back · Esc").on_press(event(Edit::Back)),
button("Use this new source")
.on_press_maybe(ready.is_ok().then(|| event(Edit::Use)))
]
.spacing(8)
)
.id("behavior-source-actions")
]
.spacing(10),
)
.padding(12)
.height(Fill)
.width(Fill)
.into(),
)
}
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id
|| self.scope.layer(p)? != self.layer
|| self
.owner
.as_ref()
.is_some_and(|e| p.elements.get(&e.id) != Some(e))
{
return Err(
"The source request's document or consumer changed. Go Back and reopen Create new."
.into(),
);
}
let kind = self
.kind
.filter(|kind| self.scope.kinds(self.layer).contains(kind))
.ok_or("Choose the source kind explicitly.")?;
if self.name.trim().is_empty() {
return Err("Enter the source name.".into());
}
if p.contains(self.id) {
return Err(
"The reserved source identity is already used. Go Back and start again.".into(),
);
}
let parent = self.parent.as_ref().map(|e| e.id);
if self
.parent
.as_ref()
.is_some_and(|e| p.elements.get(&e.id) != Some(e))
|| !validation::can_parent(p, kind, self.layer, parent)
{
return Err("The semantic parent changed. Choose its intended parent again.".into());
}
let e = Element {
id: self.id,
alias: p.fresh_alias(&self.name, "source", self.id),
name: self.name.clone(),
description: String::new(),
parent,
body: ElementBody::new(kind, self.layer)
.ok_or("This source kind is unavailable in this layer.")?,
};
let command = Command::CreateElement(e);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
}
+6 -29
View File
@@ -38,6 +38,7 @@ pub struct State {
context: Option<Context>,
tool: Option<Tool>,
pointer: Option<Point>,
focused: bool,
press: Option<Press>,
source: Option<Id>,
preview: Option<(Id, Position)>,
@@ -770,6 +771,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
canvas::Event::Window(iced::window::Event::Unfocused) => {
state.reset();
state.focused = false;
return None;
}
_ => cursor,
@@ -777,40 +779,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
let window = cursor.position();
let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
let inside = local.is_some_and(|p| Self::content(bounds).contains(p));
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event
&& state.focused
{
use keyboard::{Key, key::Named};
if *key == Key::Named(Named::Escape) {
state.reset();
return Some(Action::publish(Event::Choose(Tool::Select)).and_capture());
}
if *key == Key::Named(Named::Delete)
&& self.editable
&& self.tool == Tool::Select
&& state.press.is_none()
&& state.pan_from.is_none()
&& state.source.is_none()
&& (modifiers.is_empty() || *modifiers == keyboard::Modifiers::SHIFT)
{
let scene = self.scene(state)?;
let placement = scene
.view
.nodes
.iter()
.find(|node| node.element == self.selected || node.id == self.selected);
let object = placement.map_or(self.selected, |node| node.element);
if placement.is_none() && !scene.view.edges.iter().any(|edge| edge.id == object) {
return None;
}
return Some(
Action::publish(Event::Remove(crate::app::diagram_removal::Intent {
context: self.context,
object,
placement: placement.map(|node| node.id),
model: modifiers.shift(),
}))
.and_capture(),
);
}
if modifiers.is_empty() {
let pan = match key.as_ref() {
Key::Named(Named::ArrowLeft) => Some(Vector::new(60., 0.)),
@@ -828,6 +804,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
let scene = self.scene(state)?;
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) => {
state.focused = inside;
if !inside {
return None;
}
@@ -24,7 +24,6 @@ pub enum Tool {
#[derive(Debug, Clone)]
pub enum Event {
Remove(crate::app::diagram_removal::Intent),
Choose(Tool),
CreateView(Id),
Select(Context, Id),
@@ -71,7 +70,6 @@ pub enum Event {
impl Event {
pub fn context(&self) -> Option<Context> {
match self {
Self::Remove(intent) => Some(intent.context),
Self::Choose(_) | Self::CreateView(_) => None,
Self::Select(c, _)
| Self::Pan(c, _)
+6 -26
View File
@@ -139,14 +139,11 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
true,
));
}
entries.push(
item(
"Hide from this diagram",
Message::Command(Command::Delete(node.id)),
true,
)
.shortcut("Delete"),
);
entries.push(item(
"Hide from this diagram",
Message::Command(Command::Delete(node.id)),
true,
));
}
}
if let Some(relation) = project.relations.get(&id)
@@ -187,24 +184,7 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
}
if id != project.id {
entries.push(Entry::separator());
entries.push(item("Delete from model…", Message::Delete, true).shortcut(
if context.is_some_and(|context| {
context.diagram != id
&& (request.placement.is_some()
|| projection::project_view(project, context.diagram)
.edges
.iter()
.any(|edge| edge.id == id))
}) {
if request.placement.is_some() {
"Shift+Delete"
} else {
"Delete / Shift+Delete"
}
} else {
""
},
));
entries.push(item("Delete from model…", Message::Delete, true));
}
}
Some(Menu {
+1 -6
View File
@@ -173,12 +173,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
.width(Fill)
.height(Fill)
.into();
out = out.push(crate::diagram::focus::wrap(
canvas.map(event),
context,
crate::app::diagram_removal::enabled(app),
Message::DiagramTraverse,
));
out = out.push(canvas.map(event));
let base: Element<'_, Message> = container(out.spacing(8))
.padding(14)
.width(Fill)
+20 -143
View File
@@ -38,7 +38,6 @@ impl Tool {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Start(Id, Tool, Option<Id>),
Cancel,
Submit,
@@ -59,6 +58,7 @@ pub enum Event {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -79,11 +79,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,
@@ -91,22 +90,15 @@ pub struct State {
selected_context: Option<oa_core::topology_context::ContextAddress>,
shelf: Option<Shelf>,
}
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
search: String,
page: usize,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
#[serde(default)]
source_origin: Option<(Id, oa_core::Element)>,
#[serde(with = "recovery_insertion")]
insertion: Insertion,
fixed: bool,
@@ -125,40 +117,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())
}
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
self.draft
.as_ref()?
.source
.view(p)
.map(|v| v.map(Event::Source))
}
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
@@ -194,58 +152,6 @@ impl State {
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.tool != Tool::Work {
return Err("This reference does not support staged source creation.".into());
}
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
*document != p.id || p.elements.get(&d.owner) != Some(owner)
}) {
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
}
if d.source_origin.is_none() && d.source.engaged() {
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
}
let mut next = d.clone();
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
{
next.source_origin = Some((
p.id,
p.elements
.get(&d.owner)
.ok_or("The consumer owner no longer exists.")?
.clone(),
));
}
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Work { owner: d.owner },
d.invocation,
event,
)? {
next.target = Some(id);
next.command(p)?;
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
@@ -278,9 +184,6 @@ impl State {
}
});
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
source_origin: Some((p.id, p.elements[&owner].clone())),
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
@@ -350,8 +253,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 +263,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)
@@ -602,22 +508,6 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
if self.source.engaged()
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
*document != p.id || p.elements.get(&self.owner) != Some(owner)
})
{
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
}
self.source.check_scope(
super::source_draft::Scope::Work { owner: self.owner },
self.target,
)?;
let preview = self.source.preview(p, self.target)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.target, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
let owner = p
.elements
.get(&self.owner)
@@ -725,8 +615,6 @@ impl Draft {
});
let name = if self.name.trim().is_empty() {
default_name
} else if self.source.label(self.target).is_some() {
self.name.as_str()
} else {
self.name.trim()
};
@@ -978,8 +866,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 +1198,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 +1298,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 +1513,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| {
@@ -1705,9 +1587,6 @@ mod tests {
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
source: Default::default(),
invocation: Id::new_v4(),
source_origin: None,
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
@@ -2164,6 +2043,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 +2060,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 +2074,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(),
@@ -33,7 +33,7 @@ pub enum Event {
Search(String),
Page(usize),
}
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Draft {
edit: Option<Id>,
@@ -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;
+45 -162
View File
@@ -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,83 +188,26 @@ 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> {
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
if self
.draft
.as_ref()
.is_some_and(|d| d.source.label(d.target).is_some())
&& self.prepare_pending(p).ok().as_ref() != Some(command)
{
return None;
}
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
{
@@ -625,7 +539,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 +558,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 +583,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 +722,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 +845,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 +862,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,
File diff suppressed because it is too large Load Diff
+469 -400
View File
@@ -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,352 +285,368 @@ 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),
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(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();
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) {
form_panel = column![
text(format!(
"Consumer name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
child.map(|e| event(Event::Source(e)))
]
.spacing(6)
.height(Fill)
.into();
} else {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(contextual) = &d.contextual {
form = form
.push(
text_input("Link name (retain current when blank)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
)
.push(super::contextual::view(p, owner, contextual));
} else {
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
.align_y(iced::Center),
row![
button(text("Tools").size(12))
.padding(6)
.style(if state.shelf == Some(Shelf::Tools) {
button::primary
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed && ordered {
form = form.push(label(match (d.before, d.after) {
(Some(before), Some(after)) => format!(
"Insert between {} → {} · preserve the ordered path",
p.named(before),
p.named(after)
),
(Some(before), None) => format!("Continue after {}", p.named(before)),
(None, Some(after)) => format!("Insert before {}", p.named(after)),
_ => "Start the ordered path".into(),
}));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if d.tool == Tool::Work {
if let Some(title) = d.source.label(d.target) {
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
}
form = form.push(button("Create new source…").on_press(event(Event::Source(
super::super::source_draft::Event::Open(d.invocation),
))));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if d.tool == Tool::Branch {
"Choose first path work…"
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
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 {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
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);
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),
)
.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);
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) {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(contextual) = &d.contextual {
form = form
.push(
text_input("Link name (retain current when blank)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
)
.push(super::contextual::view(p, owner, contextual));
} else {
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed && ordered {
form = form.push(label(match (d.before, d.after) {
(Some(before), Some(after)) => format!(
"Insert between {} → {} · preserve the ordered path",
p.named(before),
p.named(after)
),
(Some(before), None) => format!("Continue after {}", p.named(before)),
(None, Some(after)) => format!("Insert before {}", p.named(after)),
_ => "Start the ordered path".into(),
}));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if d.tool == Tool::Branch {
"Choose first path work…"
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
};
let actions = row![
button(if d.contextual.is_some() {
"Apply link"
} else {
"Create"
})
.on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(d.invocation, actions.into(), form.into());
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
}
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button(if d.contextual.is_some() {
"Apply link"
} else {
"Create"
})
.on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
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)
+21 -164
View File
@@ -34,15 +34,9 @@ mod view_options_tests;
#[path = "diagram/port_labels.rs"]
mod port_labels;
pub use port_labels::PortLabels;
#[path = "diagram/focus.rs"]
pub(crate) mod focus;
#[path = "diagram/work_drag.rs"]
mod work_drag;
pub use work_drag::WorkFeedback;
#[derive(Clone, Copy)]
pub struct DiagramCanvas<'a> {
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
pub project: &'a Project,
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
pub diagram: Id,
@@ -57,9 +51,8 @@ pub struct DiagramCanvas<'a> {
}
#[derive(Default)]
pub struct State {
work: Option<Arc<crate::app::allocation_drag::Press>>,
work_cache: RefCell<Option<Arc<Scene>>>,
pointer: Option<Point>,
keyboard_focus: bool,
drag: Option<(Id, Point, Position)>,
preview: Option<Position>,
pan_start: Option<(mouse::Button, Point)>,
@@ -71,7 +64,6 @@ pub struct State {
cache: RefCell<Option<CachedScene>>,
}
struct Scene {
work: Option<Arc<crate::app::allocation_drag::Preview>>,
view: ProjectedView,
routed: Arc<RoutedView>,
}
@@ -458,26 +450,6 @@ impl DiagramCanvas<'_> {
}
}
fn scene(&self, state: &State) -> Arc<Scene> {
if let Some(preview) = self.work_preview()
&& state
.work
.as_ref()
.is_some_and(|p| p.token == preview.token)
{
if let Some(scene) = state.work_cache.borrow().as_ref()
&& scene.work.as_ref().is_some_and(|p| Arc::ptr_eq(p, preview))
{
return scene.clone();
}
let scene = Arc::new(Scene {
work: Some(preview.clone()),
view: preview.placement.view.clone(),
routed: preview.routed.clone(),
});
*state.work_cache.borrow_mut() = Some(scene.clone());
return scene;
}
*state.work_cache.borrow_mut() = None;
let preview = if state.context == Some(self.context) {
state.preview
} else {
@@ -524,11 +496,7 @@ impl DiagramCanvas<'_> {
route_diagram(self.project, &diagram, &view)
},
);
let scene = Arc::new(Scene {
work: None,
view,
routed,
});
let scene = Arc::new(Scene { view, routed });
*state.cache.borrow_mut() = Some(CachedScene {
context: self.context,
preview,
@@ -559,26 +527,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
// A completed work click retires App's allocation epoch before the
// retained canvas sees a redraw. Its already-idle press must not eat
// the first Delete. A live or not-yet-dispatched press stays guarded.
if matches!(event, canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete))
&& self.allocation.is_some_and(|capability| {
capability.clean
&& state
.work
.as_ref()
.is_some_and(|press| press.stamp.epoch != capability.state.epoch)
})
{
state.work = None;
}
if let Some(action) = self.work_lifetime(state, event, bounds) {
return Some(action);
}
if state.context != Some(self.context) {
state.work = None;
state.pointer = None;
state.port_drag = None;
state.connection_press = None;
@@ -644,6 +593,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
let local = cursor
.position()
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
if matches!(event, canvas::Event::Mouse(mouse::Event::ButtonPressed(_))) {
state.keyboard_focus = cursor.is_over(bounds);
}
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
if cursor.is_over(bounds) =>
@@ -663,8 +615,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) => {
// The mounted focus::Owner is the sole keyboard admission gate.
}) if state.keyboard_focus => {
use iced::keyboard::{Key, key::Named};
match key.as_ref() {
Key::Named(Named::F2)
@@ -719,37 +670,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
Key::Named(Named::Delete)
if self.editable
&& self.connection.is_none()
&& !self.placing
&& state.drag.is_none()
&& state.route_drag.is_none()
&& state.port_drag.is_none()
&& state.pan_start.is_none()
&& state.work.is_none()
&& (modifiers.is_empty()
|| *modifiers == iced::keyboard::Modifiers::SHIFT) =>
&& modifiers.is_empty()
&& self.selected_bend.is_some() =>
{
// An invalid/stale bend never falls through to whole-item removal.
if self.selected_bend.is_some() {
return modifiers.is_empty().then(|| {
Action::publish(Message::Route(RouteEvent::RemoveBend))
.and_capture()
});
}
let request = self.selection_context_request(state, bounds);
if request.object == self.diagram {
return None;
}
Some(
Action::publish(Message::DiagramRemove(
crate::app::diagram_removal::Intent {
context: self.context,
object: request.object,
placement: request.placement,
model: modifiers.shift(),
},
))
.and_capture(),
)
Some(Action::publish(Message::Route(RouteEvent::RemoveBend)).and_capture())
}
_ => None,
}
@@ -790,22 +714,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
let scene = self.scene(state);
let view = &scene.view;
let world = self.world(p);
if self.work_dirty_press(view, &scene.routed, p) {
state.work = None;
state.drag = None;
state.preview = None;
state.port_drag = None;
state.route_drag = None;
return Some(
Action::publish(Message::WorkDrag(
crate::app::allocation_drag::Event::Refused(
self.allocation.unwrap().state.epoch,
crate::app::allocation_drag::FINISH_EDIT.into(),
),
))
.and_capture(),
);
}
if self.placing && self.editable {
return Some(
Action::publish(Message::Architecture(
@@ -887,16 +795,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::publish(Message::Select(port.element)).and_capture());
}
if let Some(id) = self.hit(view, p) {
if self.work_eligible(id) {
return Some(self.begin_work(
state,
id,
cursor.position()?,
bounds,
view,
&scene.routed,
));
}
let position = self
.project
.diagrams
@@ -935,18 +833,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let Some(press) = &state.work {
return Some(
Action::publish(Message::WorkDrag(
crate::app::allocation_drag::Event::Preview(
press.stamp,
press.token,
cursor.position(),
),
))
.and_capture(),
);
}
let p = local?;
if let Some((button, start)) = state.pan_start {
state.pan_start = Some((button, p));
@@ -979,18 +865,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
None
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if let Some(press) = state.work.take() {
return Some(
Action::publish(Message::WorkDrag(
crate::app::allocation_drag::Event::Release(
press.stamp,
press.token,
cursor.position(),
),
))
.and_capture(),
);
}
if let Some((button, start)) = state.pan_start {
if button == mouse::Button::Middle {
return Some(Action::capture());
@@ -1137,10 +1011,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.translate(self.pan);
f.scale(self.zoom);
let scene = self.scene(state);
let project = scene
.work
.as_ref()
.map_or(self.project, |p| p.semantic.project());
let view = &scene.view;
let containers = view
.nodes
@@ -1149,10 +1019,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
.collect::<BTreeSet<_>>();
for n in &view.nodes {
if containers.contains(&n.id) {
paint_node(&mut f, project, n, self.selected, true);
paint_node(&mut f, self.project, n, self.selected, true);
}
}
let highlighted = highlighted_exchanges(project, self.selected);
let highlighted = highlighted_exchanges(self.project, self.selected);
for edge in &view.edges {
let Some(routed) = scene.routed.edges.get(&edge.id) else {
continue;
@@ -1175,7 +1045,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
} else {
Color::from_rgb8(144, 158, 173)
};
let capability_label = relationship_stereotype(project, edge.id);
let capability_label = relationship_stereotype(self.project, edge.id);
let mut stroke =
Stroke::default()
.with_color(color)
@@ -1223,7 +1093,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
..Default::default()
});
}
if let Some(relation) = project.relations.get(&edge.id) {
if let Some(relation) = self.project.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, routed) {
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
@@ -1257,13 +1127,13 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
for n in &view.nodes {
if !containers.contains(&n.id) {
paint_node(&mut f, project, n, self.selected, false);
paint_node(&mut f, self.project, n, self.selected, false);
} else {
// Container headers stay readable when relationships traverse
// the allocation enclosure. Children remain on top.
let mut header = n.clone();
header.size.height = 62.;
paint_node(&mut f, project, &header, self.selected, true);
paint_node(&mut f, self.project, &header, self.selected, true);
}
}
for port in &view.ports {
@@ -1271,17 +1141,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
style::TEAL
} else {
style::kind_color(
project.elements[&port.element]
self.project.elements[&port.element]
.parent
.and_then(|id| project.elements.get(&id))
.and_then(|id| self.project.elements.get(&id))
.map(|e| e.kind())
.unwrap_or(Kind::Port),
)
};
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
let glyph = port_glyph(&project.elements[&port.element]);
let glyph = port_glyph(&self.project.elements[&port.element]);
let path = if glyph == PortGlyph::Function {
let corners = port_triangle(&project.elements[&port.element], port);
let corners = port_triangle(&self.project.elements[&port.element], port);
Path::new(|p| {
p.move_to(corners[0]);
p.line_to(corners[1]);
@@ -1311,7 +1181,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if matches!(glyph, PortGlyph::Flow(_)) {
f.stroke(
&Path::new(|p| {
for [a, b] in flow_mark(&project.elements[&port.element], port) {
for [a, b] in flow_mark(&self.project.elements[&port.element], port) {
p.move_to(a);
p.line_to(b);
}
@@ -1473,8 +1343,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
) -> mouse::Interaction {
if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) {
mouse::Interaction::Crosshair
} else if state.work.is_some()
|| state.drag.is_some()
} else if state.drag.is_some()
|| state.pan_start.is_some()
|| state.route_drag.is_some()
|| state.port_drag.is_some()
@@ -2031,7 +1900,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
diagram,
selected: p.id,
zoom: 1.,
@@ -2106,7 +1974,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
diagram,
selected: p.id,
zoom: 0.6,
@@ -2231,7 +2098,6 @@ mod tests {
editable: true,
placing: false,
connection: Some(&session),
allocation: None,
};
let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.));
let at = |port: &oa_core::projection::ProjectedPort| {
@@ -2305,7 +2171,6 @@ mod tests {
armed.start(&p, &view, source.element);
let armed_canvas = DiagramCanvas {
connection: Some(&armed),
allocation: None,
..canvas
};
// Winit updates cursor coordinates through CursorMoved, not the
@@ -2335,7 +2200,6 @@ mod tests {
// A tool cancellation or navigation invalidates a queued release.
let cancelled = DiagramCanvas {
connection: None,
allocation: None,
context: Context {
epoch: 1,
..context
@@ -2407,7 +2271,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = State::default();
let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.);
@@ -2483,7 +2346,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = State::default();
let pin = Point::new(350., 250.);
@@ -2580,7 +2442,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = State::default();
let first = canvas.scene(&state);
@@ -2708,7 +2569,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
for end in ends {
assert!(svg.contains(&format!(
@@ -2762,7 +2622,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = State::default();
let scene = canvas.scene(&state);
@@ -2836,7 +2695,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),point(cover)+canvas.pan),Some(Message::SelectNode(id)) if id==node.id)
@@ -2869,7 +2727,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let span = &routed.edges[&edge].points;
let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap());
-244
View File
@@ -1,244 +0,0 @@
//! Iced focus ownership shared by architecture and persisted state canvases.
//! Focus operations (including clean Inspector focus) unfocus this widget.
use crate::route_edit::Context;
use iced::advanced::widget::{Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event as InputEvent, Length, Point, Rectangle, Size, Theme, Vector, mouse};
#[derive(Default)]
struct Input {
context: Option<Context>,
focused: bool,
traversing: bool,
pointer: Option<Point>,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
self.traversing = false;
}
fn unfocus(&mut self) {
self.focused = false;
self.traversing = false;
}
}
struct Owner<'a, Message, R: renderer::Renderer> {
context: Context,
enabled: bool,
traverse: Box<dyn Fn(bool) -> Message + 'a>,
content: Element<'a, Message, Theme, R>,
}
pub fn wrap<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
context: Context,
enabled: bool,
traverse: impl Fn(bool) -> Message + 'a,
) -> Element<'a, Message, Theme, R> {
Element::new(Owner {
context,
enabled,
traverse: Box::new(traverse),
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Message, R> {
fn tag(&self) -> iced::advanced::widget::tree::Tag {
iced::advanced::widget::tree::Tag::of::<Input>()
}
fn state(&self) -> iced::advanced::widget::tree::State {
iced::advanced::widget::tree::State::new(Input {
context: Some(self.context),
..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.context != Some(self.context) || !self.enabled {
state.focused = false;
state.traversing = false;
state.pointer = None;
}
state.context = Some(self.context);
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 self.enabled {
operation.focusable(
Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")),
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: &InputEvent,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<Input>();
if let InputEvent::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
if matches!(
event,
InputEvent::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
) || !self.enabled
{
state.focused = false;
state.traversing = false;
state.pointer = None;
}
if matches!(event, InputEvent::Mouse(mouse::Event::ButtonPressed(_))) {
state.traversing = false;
let pointer = state.pointer.or_else(|| cursor.position());
state.focused = self.enabled
&& !shell.is_event_captured()
&& cursor.position().is_some()
&& pointer.is_some_and(|p| {
layout
.bounds()
.intersection(viewport)
.is_some_and(|b| b.contains(p))
});
}
if let InputEvent::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
modifiers,
..
}) = event
&& state.focused
&& !state.traversing
&& self.enabled
&& !shell.is_event_captured()
&& (*modifiers == iced::keyboard::Modifiers::SHIFT || modifiers.is_empty())
{
// The focus task runs after the native batch. Retain focus for its
// traversal count, but reject later keys in this same batch now.
state.traversing = true;
shell.publish((self.traverse)(modifiers.shift()));
shell.capture_event();
return;
}
if matches!(event, InputEvent::Keyboard(_))
&& (!state.focused || state.traversing || !self.enabled)
{
return;
}
if shell.is_event_captured() {
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,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
let state = tree.state.downcast_ref::<Input>();
if state.focused && !state.traversing && self.enabled {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: iced::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 {
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,
)
}
}
@@ -64,7 +64,6 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
editable: true,
placing: false,
connection: app.connection.as_ref(),
allocation: None,
}
}
fn screen(app: &App, id: Id) -> Point {
@@ -72,7 +72,6 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
editable: true,
placing: false,
connection: app.connection.as_ref(),
allocation: None,
}
}
fn event(
@@ -18,7 +18,6 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
editable: true,
placing: false,
connection: None,
allocation: None,
}
}
@@ -58,9 +58,6 @@ impl canvas::Program<Message> for PortLabels<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
if self.0.work_preview().is_some() {
return vec![]; // The candidate canvas already paints its own ports.
}
if state.context == Some(self.0.context) && state.held_buttons != 0 {
return vec![];
}
@@ -188,7 +185,6 @@ mod tests {
editable: true,
placing: false,
connection: None,
allocation: None,
})
}
-205
View File
@@ -1,205 +0,0 @@
use super::*;
use crate::app::allocation_drag::{self as input, Event, Press, Stamp};
impl DiagramCanvas<'_> {
pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool {
let diagram = &self.project.diagrams[&self.diagram];
let view = self.projected(diagram);
let routed = routing::route_diagram(self.project, diagram, &view);
self.work_dirty_press(
&view,
&routed,
Point::new(screen.x - bounds.x, screen.y - bounds.y),
)
}
pub(super) fn work_preview(&self) -> Option<&Arc<input::Preview>> {
let capability = self.allocation?;
let feedback = capability.state.feedback.as_ref()?;
(capability.clean
&& feedback.press.stamp.context == self.context
&& feedback.press.stamp.epoch == capability.state.epoch
&& feedback.press.stamp.pan == self.pan
&& feedback.press.stamp.zoom == self.zoom)
.then_some(capability.state.preview.as_ref())
.flatten()
}
pub(super) fn work_eligible(&self, placement: Id) -> bool {
self.allocation.is_some()
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
&& self.project.diagrams[&self.diagram]
.nodes
.get(&placement)
.and_then(|n| self.project.elements.get(&n.element))
.is_some_and(|e| e.kind().is_work())
}
pub(super) fn work_dirty_press(
&self,
view: &ProjectedView,
routed: &RoutedView,
local: Point,
) -> bool {
self.allocation.is_some_and(|c| !c.clean)
&& !self.placing
&& self.connection.is_none()
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
&& (self.hit(view, local).is_some()
|| self.hit_port(view, local).is_some()
|| self.hit_edge(routed, local).is_some()
|| self.hit_label(routed, local).is_some()
|| self.hit_association_label(routed, local).is_some())
}
pub(super) fn begin_work(
&self,
state: &mut State,
node: Id,
screen: Point,
bounds: Rectangle,
view: &ProjectedView,
routed: &RoutedView,
) -> Action<Message> {
let capability = self.allocation.unwrap();
state.drag = None;
state.preview = None;
state.port_drag = None;
state.route_drag = None;
if !capability.clean {
return Action::publish(Message::WorkDrag(Event::Refused(
capability.state.epoch,
input::FINISH_EDIT.into(),
)))
.and_capture();
}
let work = self.project.diagrams[&self.diagram].nodes[&node].element;
let scope = match crate::allocation_drag::capture(self.project, self.diagram, work, node) {
Ok(scope) => scope,
Err(reason) => {
return Action::publish(Message::WorkDrag(Event::Refused(
capability.state.epoch,
input::refusal(&reason),
)))
.and_capture();
}
};
let frozen = input::Frozen::capture(self.project, &scope, view, routed, self.zoom);
let press = Arc::new(Press {
stamp: Stamp {
context: self.context,
epoch: capability.state.epoch,
snapshot: capability.snapshot,
bounds,
pan: self.pan,
zoom: self.zoom,
},
token: Id::new_v4(),
start: screen,
scope,
frozen,
arrange: capability.state.arrange_next,
});
state.work = Some(press.clone());
Action::publish(Message::WorkDrag(Event::Press(press))).and_capture()
}
pub(super) fn work_lifetime(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
) -> Option<Action<Message>> {
let press = state.work.as_ref()?;
let cancel = self
.allocation
.is_none_or(|c| !c.clean || c.state.epoch != press.stamp.epoch)
|| press.stamp.context != self.context
|| press.stamp.bounds != bounds
|| press.stamp.pan != self.pan
|| press.stamp.zoom != self.zoom
|| matches!(
event,
canvas::Event::Window(iced::window::Event::Unfocused)
| canvas::Event::Mouse(mouse::Event::CursorLeft)
| canvas::Event::Mouse(mouse::Event::ButtonPressed(
mouse::Button::Middle | mouse::Button::Right
))
| canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
)
|| matches!(event, canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape));
if cancel {
let token = press.token;
state.work = None;
state.pointer = None;
return Some(Action::publish(Message::WorkDrag(Event::Cancel(token))).and_capture());
}
None
}
}
/// Separate passive layer: frozen target labels remain above all candidate card
/// text. It does not acquire input or use a second independent semantic scene.
pub struct WorkFeedback<'a>(pub DiagramCanvas<'a>);
impl canvas::Program<Message> for WorkFeedback<'_> {
type State = ();
fn draw(
&self,
_: &(),
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let Some(capability) = self.0.allocation else {
return vec![];
};
let Some(feedback) = &capability.state.feedback else {
return vec![];
};
if feedback.press.stamp.epoch != capability.state.epoch
|| feedback.press.stamp.context != self.0.context
|| feedback.press.stamp.bounds != bounds
{
return vec![];
}
let mut frame = Frame::new(renderer, bounds.size());
let color = if feedback.refused {
Color::from_rgb8(173, 66, 41)
} else {
style::TEAL
};
for index in &feedback.targets {
let target = &feedback.press.frozen.targets[*index];
let region = target.region;
let top = Point::new(
region.x * self.0.zoom + self.0.pan.x,
region.y * self.0.zoom + self.0.pan.y,
);
frame.stroke(
&Path::rectangle(
top,
iced::Size::new(region.width * self.0.zoom, region.height * self.0.zoom),
),
Stroke::default().with_color(color).with_width(3.),
);
let label = target.label;
let top = Point::new(
label.position.x * self.0.zoom + self.0.pan.x,
label.position.y * self.0.zoom + self.0.pan.y,
);
frame.fill_rectangle(
top,
iced::Size::new(
label.size.width * self.0.zoom,
label.size.height * self.0.zoom,
),
Color::WHITE,
);
frame.fill_text(Text {
content: target.name.clone(),
position: top,
size: (16. * self.0.zoom).into(),
color,
..Default::default()
});
}
vec![frame.into_geometry()]
}
}
+4 -18
View File
@@ -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> {
+35 -22
View File
@@ -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) => {
+1 -1
View File
@@ -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,
);
}
+23 -44
View File
@@ -188,7 +188,7 @@ fn article(topic: Topic) -> Article {
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
"Expand the diagram's side palette to see tool names. Choose Entity, click its position in the diagram, enter Rescue coordinator and press Enter. Escape cancels the draft. Use Actor for an external participant such as a Vessel in distress. Right-clicking a blank area also offers Create Entity here.",
"Right-click Rescue coordinator and choose Add activity. Enter Assess incident and press Enter to create the activity and its performer allocation together. Ordinary Activity creation in the palette leaves work unallocated. In the allocated-work view, drag existing work into exposed space inside its performer to assign it; the highlighted performer and status explain the proposed change.",
"Right-click Rescue coordinator and choose Add activity. Enter Assess incident and press Enter to create the activity and its performer allocation together. Ordinary Activity creation in the palette leaves work unallocated; use + Relationship with Allocation to assign existing work. The allocated-work view shows work inside its performer.",
"Use Add existing to place an element that is already in the model. The initial choices match this view; no search is required. Include already shown offers Locate for elements already placed here.",
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
@@ -263,7 +263,7 @@ fn article(topic: Topic) -> Article {
&[
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
"In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. + Relationship with Allocation and Model explorer's + Allocated provide explicit alternatives.",
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. To assign existing leaf work, use + Relationship with Allocation from the participant to the work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
],
),
@@ -289,7 +289,7 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.",
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange next drag Alt+A auto-layout fit fold hide zoom resize routing connection bend waypoint detour label reset selective visibility category categories coverage process context duplicate copy items",
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset selective visibility category categories coverage process context duplicate copy items",
sections: &[
explain(
"Three things to distinguish",
@@ -309,7 +309,7 @@ fn article(topic: Topic) -> Article {
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
"Select a diagram node to access Placement, Fold / unfold and Hide. Hide removes its view placement; Delete from model removes model records and requires review of the affected references.",
],
),
@@ -317,14 +317,6 @@ fn article(topic: Topic) -> Article {
"Keep diagrams readable",
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
),
walkthrough(
"Move work between performers",
&[
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space. Read the highlighted destination and status before releasing; release outside the canvas or press Escape to cancel.",
"The move preserves work ownership and identities. A uniformly assigned work group moves its leaves together; a group distributed across performers asks you to choose a leaf. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
"Make room when headers, labels or other cards would overlap. Move the view or zoom in if the work and destination are not readable. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
],
),
walkthrough(
"Save a focused exchange view",
&[
@@ -344,7 +336,7 @@ fn article(topic: Topic) -> Article {
&[
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
@@ -453,32 +445,28 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Describe a path through exchanges",
&[
"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.",
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
"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 path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"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.",
],
),
@@ -502,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 + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
"Use Action, Observe state or Fragment for other behavior. 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 a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
"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.",
"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 Fragment and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
"Use the Destination selector in subsequent message or action forms to choose the intended operand. New occurrences append to that operand's ordered behavior.",
"Use Reuse scenario to refer to existing behavior. Bind every referenced lifeline to a compatible local lifeline explicitly. Open source follows the original scenario without copying its contents.",
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
],
),
@@ -559,8 +543,7 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Build a state machine",
&[
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.",
"If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.",
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.",
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
@@ -714,10 +697,6 @@ fn article(topic: Topic) -> Article {
"Map references by meaning",
"Reference mapping connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
),
explain(
"Edit stored observations deliberately",
"In a trace's Properties, Edit stored history (advanced)… edits the observations retained by that trace. Choose the fact and enter its Operational and System observations explicitly. Not applicable, an observed absent value, and no stored fact have different meanings. Historical references may retain the exact UUID of a deleted record. Apply changes validates the complete history; Undo restores it. These history edits leave both counterpart records unchanged. Use Review counterpart changes… for Keep/Take decisions about their current engineering content.",
),
walkthrough(
"Review one message value or lifeline binding",
&[
@@ -918,7 +897,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"A new behavior is still a draft",
"Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
"Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
),
explain(
"Aliases and references",
-2
View File
@@ -1,6 +1,4 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod allocation_drag;
mod allocation_input;
mod app;
mod behavior;
mod connection_edit;
+2 -22
View File
@@ -415,9 +415,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
true,
));
entries.push(item("Placement properties", Message::Select(node.id), true));
entries.push(
item("Hide from this diagram", Message::HideSelected, true).shortcut("Delete"),
);
entries.push(item("Hide from this diagram", Message::HideSelected, true));
} else if e.kind() != Kind::Port
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
{
@@ -467,25 +465,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
true,
));
if id != p.id {
entries.push(
item("Delete from model…", Message::Delete, true).shortcut(
if request.canvas.is_some_and(|context| context.diagram != id)
&& (request.placement.is_some()
|| p.relations.contains_key(&id)
|| p.elements
.get(&id)
.is_some_and(|element| element.kind() == Kind::Port))
{
if request.placement.is_some() {
"Shift+Delete"
} else {
"Delete / Shift+Delete"
}
} else {
""
},
),
);
entries.push(item("Delete from model…", Message::Delete, true));
}
}
Some(Menu {
-3
View File
@@ -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),
@@ -1,147 +1,147 @@
use super::artifacts::{Editor, Event, Raw};
use super::*;
use iced::{
Element as View, Fill,
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
};
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
for (layer, label, selected) in [
(Layer::Oa, "Operational", editor.oa.clone()),
(Layer::Sa, "System", editor.sa.clone()),
] {
content = content.push(text(label).size(12)).push(
row![
pick_list(editor.choices(p, trace, layer), selected, move |v| {
Event::Pick(layer, Some(v))
})
.placeholder("Choose exact artifact identity")
.width(Fill)
.padding(7),
button("No identity")
.padding(7)
.on_press(Event::Pick(layer, None))
]
.spacing(7),
);
}
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
p: &'a Project,
facet: &'a FacetSelector,
) -> Option<View<'a, Event>> {
let value = editor.drafts.get(facet)?;
let (_, which) = artifacts::parts(facet)?;
let mut content =
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
match value {
Raw::Text(_) if which == ArtifactFacet::Description => {
let draft = editor.descriptions.get(facet)?;
let facet = facet.clone();
content = content.push(
text_editor(draft)
.height(110)
.on_action(move |action| Event::Description(facet.clone(), action)),
);
}
Raw::Text(value) => {
let facet = facet.clone();
content = content.push(
text_input("Exact text", value)
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
.padding(7),
);
}
Raw::Owner(value) => {
let mut choices: Vec<_> = p
.identities()
.into_iter()
.map(|(id, alias)| Choice {
id,
label: format!("{} · @{alias}", p.named(id)),
})
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
let raw_facet = facet.clone();
let pick_facet = facet.clone();
content = content
.push(
pick_list(choices, selected, move |v: Choice| {
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
})
.placeholder("Explicit final owner")
.width(Fill)
.padding(7),
)
.push(
text_input("Owner identity (retains rejected input)", value)
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
.padding(7),
);
}
Raw::Relation(value) => {
let facet = facet.clone();
let variants = [
ArtifactRelation::References,
ArtifactRelation::Satisfies,
ArtifactRelation::Verifies,
ArtifactRelation::Implements,
ArtifactRelation::DerivedFrom,
];
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
content = content.push(
pick_list(choices, Some(RelationChoice(*value)), move |v| {
Event::Input(facet.clone(), Raw::Relation(v.0))
})
.padding(7),
);
}
Raw::Optional {
present,
text: value,
} => {
let toggle_facet = facet.clone();
let retained = value.clone();
let input_facet = facet.clone();
let enabled = *present;
content = content
.push(
checkbox(*present)
.label("Present (checked with empty text means an explicit empty value)")
.on_toggle(move |present| {
Event::Input(
toggle_facet.clone(),
Raw::Optional {
present,
text: retained.clone(),
},
)
}),
)
.push(
text_input("Retained optional text", value)
.on_input(move |text| {
Event::Input(
input_facet.clone(),
Raw::Optional {
present: enabled,
text,
},
)
})
.padding(7),
);
}
}
Some(content.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RelationChoice(ArtifactRelation);
impl std::fmt::Display for RelationChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
use super::artifacts::{Editor, Event, Raw};
use super::*;
use iced::{
Element as View, Fill,
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
};
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
for (layer, label, selected) in [
(Layer::Oa, "Operational", editor.oa.clone()),
(Layer::Sa, "System", editor.sa.clone()),
] {
content = content.push(text(label).size(12)).push(
row![
pick_list(editor.choices(p, trace, layer), selected, move |v| {
Event::Pick(layer, Some(v))
})
.placeholder("Choose exact artifact identity")
.width(Fill)
.padding(7),
button("No identity")
.padding(7)
.on_press(Event::Pick(layer, None))
]
.spacing(7),
);
}
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
p: &'a Project,
facet: &'a FacetSelector,
) -> Option<View<'a, Event>> {
let value = editor.drafts.get(facet)?;
let (_, which) = artifacts::parts(facet)?;
let mut content =
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
match value {
Raw::Text(_) if which == ArtifactFacet::Description => {
let draft = editor.descriptions.get(facet)?;
let facet = facet.clone();
content = content.push(
text_editor(draft)
.height(110)
.on_action(move |action| Event::Description(facet.clone(), action)),
);
}
Raw::Text(value) => {
let facet = facet.clone();
content = content.push(
text_input("Exact text", value)
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
.padding(7),
);
}
Raw::Owner(value) => {
let mut choices: Vec<_> = p
.identities()
.into_iter()
.map(|(id, alias)| Choice {
id,
label: format!("{} · @{alias}", p.named(id)),
})
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
let raw_facet = facet.clone();
let pick_facet = facet.clone();
content = content
.push(
pick_list(choices, selected, move |v: Choice| {
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
})
.placeholder("Explicit final owner")
.width(Fill)
.padding(7),
)
.push(
text_input("Owner identity (retains rejected input)", value)
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
.padding(7),
);
}
Raw::Relation(value) => {
let facet = facet.clone();
let variants = [
ArtifactRelation::References,
ArtifactRelation::Satisfies,
ArtifactRelation::Verifies,
ArtifactRelation::Implements,
ArtifactRelation::DerivedFrom,
];
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
content = content.push(
pick_list(choices, Some(RelationChoice(*value)), move |v| {
Event::Input(facet.clone(), Raw::Relation(v.0))
})
.padding(7),
);
}
Raw::Optional {
present,
text: value,
} => {
let toggle_facet = facet.clone();
let retained = value.clone();
let input_facet = facet.clone();
let enabled = *present;
content = content
.push(
checkbox(*present)
.label("Present (checked with empty text means an explicit empty value)")
.on_toggle(move |present| {
Event::Input(
toggle_facet.clone(),
Raw::Optional {
present,
text: retained.clone(),
},
)
}),
)
.push(
text_input("Retained optional text", value)
.on_input(move |text| {
Event::Input(
input_facet.clone(),
Raw::Optional {
present: enabled,
text,
},
)
})
.padding(7),
);
}
}
Some(content.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RelationChoice(ArtifactRelation);
impl std::fmt::Display for RelationChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
@@ -1,79 +1,79 @@
//! Explicit attachment pair and final destination field controls.
use super::{
attachments::{Editor, Event, Family},
*,
};
use iced::{
Element as View, Fill,
widget::{button, column, pick_list, row, text, text_input},
};
pub(super) fn pairs<'a>(
editor: &'a Editor,
project: &'a Project,
trace: &'a Trace,
) -> View<'a, Event> {
let mut content = column![
text("Existing children and relationship endpoints").size(16),
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
].spacing(9);
for layer in [Layer::Oa, Layer::Sa] {
let label = if layer == Layer::Oa {
"Operational"
} else {
"System"
};
let owner = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
content = content.push(
row![
pick_list(
editor.choices(project, trace, layer),
editor.selection(layer).cloned(),
move |choice| Event::Pick(layer, Some(choice))
)
.placeholder(format!("Choose an existing {label} identity"))
.text_size(12)
.padding(7)
.width(Fill),
button(text("No identity on this side").size(11))
.style(crate::style::nav)
.padding(7)
.on_press(Event::Pick(layer, None)),
]
.spacing(7),
);
}
content = content.push(
button(text("Use exact attachment pair").size(12))
.padding(8)
.style(crate::style::nav)
.on_press(Event::Stage),
);
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
project: &'a Project,
facet: &'a FacetSelector,
layer: Layer,
) -> Option<View<'a, Message>> {
let draft = editor.drafts.get(facet)?;
let (id, _) = attachments::actual_slot(facet, layer)?;
let path = attachments::path(project, facet, layer)?;
Some(
column![
text(format!("Destination record: {}", project.named(id))).size(12),
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
]
.spacing(7)
.into(),
)
}
//! Explicit attachment pair and final destination field controls.
use super::{
attachments::{Editor, Event, Family},
*,
};
use iced::{
Element as View, Fill,
widget::{button, column, pick_list, row, text, text_input},
};
pub(super) fn pairs<'a>(
editor: &'a Editor,
project: &'a Project,
trace: &'a Trace,
) -> View<'a, Event> {
let mut content = column![
text("Existing children and relationship endpoints").size(16),
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
].spacing(9);
for layer in [Layer::Oa, Layer::Sa] {
let label = if layer == Layer::Oa {
"Operational"
} else {
"System"
};
let owner = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
content = content.push(
row![
pick_list(
editor.choices(project, trace, layer),
editor.selection(layer).cloned(),
move |choice| Event::Pick(layer, Some(choice))
)
.placeholder(format!("Choose an existing {label} identity"))
.text_size(12)
.padding(7)
.width(Fill),
button(text("No identity on this side").size(11))
.style(crate::style::nav)
.padding(7)
.on_press(Event::Pick(layer, None)),
]
.spacing(7),
);
}
content = content.push(
button(text("Use exact attachment pair").size(12))
.padding(8)
.style(crate::style::nav)
.on_press(Event::Stage),
);
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
project: &'a Project,
facet: &'a FacetSelector,
layer: Layer,
) -> Option<View<'a, Message>> {
let draft = editor.drafts.get(facet)?;
let (id, _) = attachments::actual_slot(facet, layer)?;
let path = attachments::path(project, facet, layer)?;
Some(
column![
text(format!("Destination record: {}", project.named(id))).size(12),
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
]
.spacing(7)
.into(),
)
}
+6 -82
View File
@@ -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(
+350 -350
View File
@@ -1,350 +1,350 @@
//! Closed presence-only adapter. The core independently validates the full final record.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Family {
Member(ReferenceCollection),
Literal,
Annotation,
Binding,
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
Self::Literal => "Enumeration literals",
Self::Annotation => "Message values",
Self::Binding => "Scenario lifeline bindings",
})
}
}
pub(super) const FAMILIES: [Family; 13] = [
Family::Member(ReferenceCollection::Steps),
Family::Member(ReferenceCollection::FragmentBranches),
Family::Member(ReferenceCollection::FunctionInputItems),
Family::Member(ReferenceCollection::FunctionOutputItems),
Family::Member(ReferenceCollection::SignalItems),
Family::Member(ReferenceCollection::ExchangeItems),
Family::Member(ReferenceCollection::CategoryExchanges),
Family::Member(ReferenceCollection::TransitionTriggers),
Family::Member(ReferenceCollection::TransitionEffects),
Family::Member(ReferenceCollection::Constrained),
Family::Literal,
Family::Annotation,
Family::Binding,
];
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
let family = match facet {
FacetSelector::Member { collection, .. } => Family::Member(*collection),
FacetSelector::Literal(_) => Family::Literal,
FacetSelector::Annotation(_) => Family::Annotation,
FacetSelector::Binding(_) => Family::Binding,
_ => return None,
};
FAMILIES.contains(&family).then_some(family)
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
family(facet).is_some()
}
// Fixed paths for the closed collections and the exact enum literal set.
// They are selected by the typed facet; callers cannot supply arbitrary paths.
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
let tails: &[&str] = match family {
Family::Member(ReferenceCollection::Steps) => {
return [
"/body/Oa/Process/steps",
"/body/Sa/FunctionalChain/steps",
"/body/Oa/Shared/Scenario/steps",
"/body/Sa/Shared/Scenario/steps",
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
]
.into_iter()
.find(|p| value.pointer(p).is_some_and(Value::is_array))
.map(str::to_owned);
}
Family::Member(ReferenceCollection::FragmentBranches) => {
&["Occurrence/body/Fragment/branches"]
}
Family::Member(ReferenceCollection::FunctionInputItems) => {
&["Port/contract/FunctionInput/incoming_items"]
}
Family::Member(ReferenceCollection::FunctionOutputItems) => {
&["Port/contract/FunctionOutput/outgoing_items"]
}
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
Family::Literal => &["DataType/definition/Enumeration/literals"],
Family::Annotation => &["Occurrence/body/Exchange/values"],
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
Family::Member(ReferenceCollection::ExchangeItems) => {
return [
"Interaction",
"FunctionalExchange",
"BoundaryExchange",
"CommunicationMean",
]
.iter()
.map(|kind| format!("/body/{kind}/items"))
.find(|p| value.pointer(p).is_some_and(Value::is_array));
}
Family::Member(ReferenceCollection::TransitionTriggers) => {
return value
.pointer("/body/StateTransition/triggers")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/triggers".into());
}
Family::Member(ReferenceCollection::TransitionEffects) => {
return value
.pointer("/body/StateTransition/effects")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/effects".into());
}
};
prefixes
.iter()
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
.find(|p| value.pointer(p).is_some_and(Value::is_array))
}
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Id")),
FacetSelector::Literal(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|v| Value::String(v.clone())),
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|key| serde_json::to_value(key).expect("Annotation key")),
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Binding key")),
_ => None,
}
}
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
if family(a).is_none() || family(a) != family(b) {
return false;
}
[Layer::Oa, Layer::Sa]
.into_iter()
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
}
pub(super) fn project_presence(
value: &mut Value,
facet: &FacetSelector,
layer: Layer,
present: bool,
) -> Result<(), String> {
let family = family(facet).ok_or("This keyed family remains read-only.")?;
let key = key(facet, layer)
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
let path =
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
let list = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination collection is unavailable.")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|v| v != &key);
}
Ok(())
}
impl ReviewRow {
pub(super) fn keyed_value(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if keyed_rows::supported(facet) {
return self.project_row(project, trace, value, facet, decision);
}
let present = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::TakeSource => {
// A missing source identity is a target-local row, not a removal.
if key(facet, self.direction.source_layer()).is_none() {
return Ok(());
}
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
}
SemanticDecision::Candidate => *self
.presence
.get(facet)
.ok_or("Choose final membership presence.")?,
};
project_presence(value, facet, self.direction.target_layer(), present)
}
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let values: Vec<_> = [trace.oa, trace.sa]
.into_iter()
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
.collect();
FAMILIES
.into_iter()
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
.collect()
}
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
if self.member_family == Family::Binding {
return self.binding_choices(project, layer, true);
}
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
let Some(mut draft) = Draft::from_project(project, id) else {
return vec![];
};
if layer == self.direction.target_layer() {
draft.set_context(self.combined_value(project, true).ok());
}
let selected = if layer == Layer::Oa {
&self.member_oa
} else {
&self.member_sa
};
let Some(path) = path(&draft.context_value(), self.member_family) else {
return selected.iter().cloned().collect();
};
crate::forms::reference_choices(
&draft,
project,
&format!("{path}/0"),
selected.as_ref().map_or(Id::nil(), |c| c.id),
)
.into_iter()
.filter(|c| !c.id.is_nil())
.collect()
}
pub(super) fn edit_pair(
&mut self,
project: &Project,
facet: FacetSelector,
) -> Result<(), String> {
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
match facet {
FacetSelector::Annotation(keys) => {
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
}
FacetSelector::Binding(keys) => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Member { keys, .. } => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Literal(keys) => {
self.literal_oa = keys.oa.is_some();
self.literal_sa = keys.sa.is_some();
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
}
_ => unreachable!(),
}
self.tab = Tab::Members;
Ok(())
}
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
if !FAMILIES.contains(&self.member_family) {
return Err("This keyed family remains read-only.".into());
}
let facet = match self.member_family {
Family::Member(collection) => FacetSelector::Member {
collection,
keys: KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
},
},
Family::Literal => FacetSelector::Literal(KeyPair {
oa: self.literal_oa.then(|| self.literal.clone()),
sa: self.literal_sa.then(|| self.literal.clone()),
}),
Family::Annotation => FacetSelector::Annotation(KeyPair {
oa: self
.annotation_oa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
sa: self
.annotation_sa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
}),
Family::Binding => FacetSelector::Binding(KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
}),
};
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
capture_facet(project, trace.oa, trace.sa, &facet)?;
self.stage_row_references(&facet)?;
if let FacetSelector::Member {
keys:
KeyPair {
oa: Some(oa),
sa: Some(sa),
},
..
} = &facet
{
self.references.insert(*oa, *sa);
}
self.staged.insert(facet.clone());
self.selected = Some(facet);
self.tab = Tab::Facts;
Ok(())
}
}
//! Closed presence-only adapter. The core independently validates the full final record.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Family {
Member(ReferenceCollection),
Literal,
Annotation,
Binding,
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
Self::Literal => "Enumeration literals",
Self::Annotation => "Message values",
Self::Binding => "Scenario lifeline bindings",
})
}
}
pub(super) const FAMILIES: [Family; 13] = [
Family::Member(ReferenceCollection::Steps),
Family::Member(ReferenceCollection::FragmentBranches),
Family::Member(ReferenceCollection::FunctionInputItems),
Family::Member(ReferenceCollection::FunctionOutputItems),
Family::Member(ReferenceCollection::SignalItems),
Family::Member(ReferenceCollection::ExchangeItems),
Family::Member(ReferenceCollection::CategoryExchanges),
Family::Member(ReferenceCollection::TransitionTriggers),
Family::Member(ReferenceCollection::TransitionEffects),
Family::Member(ReferenceCollection::Constrained),
Family::Literal,
Family::Annotation,
Family::Binding,
];
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
let family = match facet {
FacetSelector::Member { collection, .. } => Family::Member(*collection),
FacetSelector::Literal(_) => Family::Literal,
FacetSelector::Annotation(_) => Family::Annotation,
FacetSelector::Binding(_) => Family::Binding,
_ => return None,
};
FAMILIES.contains(&family).then_some(family)
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
family(facet).is_some()
}
// Fixed paths for the closed collections and the exact enum literal set.
// They are selected by the typed facet; callers cannot supply arbitrary paths.
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
let tails: &[&str] = match family {
Family::Member(ReferenceCollection::Steps) => {
return [
"/body/Oa/Process/steps",
"/body/Sa/FunctionalChain/steps",
"/body/Oa/Shared/Scenario/steps",
"/body/Sa/Shared/Scenario/steps",
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
]
.into_iter()
.find(|p| value.pointer(p).is_some_and(Value::is_array))
.map(str::to_owned);
}
Family::Member(ReferenceCollection::FragmentBranches) => {
&["Occurrence/body/Fragment/branches"]
}
Family::Member(ReferenceCollection::FunctionInputItems) => {
&["Port/contract/FunctionInput/incoming_items"]
}
Family::Member(ReferenceCollection::FunctionOutputItems) => {
&["Port/contract/FunctionOutput/outgoing_items"]
}
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
Family::Literal => &["DataType/definition/Enumeration/literals"],
Family::Annotation => &["Occurrence/body/Exchange/values"],
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
Family::Member(ReferenceCollection::ExchangeItems) => {
return [
"Interaction",
"FunctionalExchange",
"BoundaryExchange",
"CommunicationMean",
]
.iter()
.map(|kind| format!("/body/{kind}/items"))
.find(|p| value.pointer(p).is_some_and(Value::is_array));
}
Family::Member(ReferenceCollection::TransitionTriggers) => {
return value
.pointer("/body/StateTransition/triggers")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/triggers".into());
}
Family::Member(ReferenceCollection::TransitionEffects) => {
return value
.pointer("/body/StateTransition/effects")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/effects".into());
}
};
prefixes
.iter()
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
.find(|p| value.pointer(p).is_some_and(Value::is_array))
}
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Id")),
FacetSelector::Literal(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|v| Value::String(v.clone())),
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|key| serde_json::to_value(key).expect("Annotation key")),
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Binding key")),
_ => None,
}
}
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
if family(a).is_none() || family(a) != family(b) {
return false;
}
[Layer::Oa, Layer::Sa]
.into_iter()
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
}
pub(super) fn project_presence(
value: &mut Value,
facet: &FacetSelector,
layer: Layer,
present: bool,
) -> Result<(), String> {
let family = family(facet).ok_or("This keyed family remains read-only.")?;
let key = key(facet, layer)
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
let path =
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
let list = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination collection is unavailable.")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|v| v != &key);
}
Ok(())
}
impl ReviewRow {
pub(super) fn keyed_value(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if keyed_rows::supported(facet) {
return self.project_row(project, trace, value, facet, decision);
}
let present = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::TakeSource => {
// A missing source identity is a target-local row, not a removal.
if key(facet, self.direction.source_layer()).is_none() {
return Ok(());
}
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
}
SemanticDecision::Candidate => *self
.presence
.get(facet)
.ok_or("Choose final membership presence.")?,
};
project_presence(value, facet, self.direction.target_layer(), present)
}
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let values: Vec<_> = [trace.oa, trace.sa]
.into_iter()
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
.collect();
FAMILIES
.into_iter()
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
.collect()
}
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
if self.member_family == Family::Binding {
return self.binding_choices(project, layer, true);
}
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
let Some(mut draft) = Draft::from_project(project, id) else {
return vec![];
};
if layer == self.direction.target_layer() {
draft.set_context(self.combined_value(project, true).ok());
}
let selected = if layer == Layer::Oa {
&self.member_oa
} else {
&self.member_sa
};
let Some(path) = path(&draft.context_value(), self.member_family) else {
return selected.iter().cloned().collect();
};
crate::forms::reference_choices(
&draft,
project,
&format!("{path}/0"),
selected.as_ref().map_or(Id::nil(), |c| c.id),
)
.into_iter()
.filter(|c| !c.id.is_nil())
.collect()
}
pub(super) fn edit_pair(
&mut self,
project: &Project,
facet: FacetSelector,
) -> Result<(), String> {
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
match facet {
FacetSelector::Annotation(keys) => {
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
}
FacetSelector::Binding(keys) => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Member { keys, .. } => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Literal(keys) => {
self.literal_oa = keys.oa.is_some();
self.literal_sa = keys.sa.is_some();
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
}
_ => unreachable!(),
}
self.tab = Tab::Members;
Ok(())
}
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
if !FAMILIES.contains(&self.member_family) {
return Err("This keyed family remains read-only.".into());
}
let facet = match self.member_family {
Family::Member(collection) => FacetSelector::Member {
collection,
keys: KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
},
},
Family::Literal => FacetSelector::Literal(KeyPair {
oa: self.literal_oa.then(|| self.literal.clone()),
sa: self.literal_sa.then(|| self.literal.clone()),
}),
Family::Annotation => FacetSelector::Annotation(KeyPair {
oa: self
.annotation_oa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
sa: self
.annotation_sa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
}),
Family::Binding => FacetSelector::Binding(KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
}),
};
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
capture_facet(project, trace.oa, trace.sa, &facet)?;
self.stage_row_references(&facet)?;
if let FacetSelector::Member {
keys:
KeyPair {
oa: Some(oa),
sa: Some(sa),
},
..
} = &facet
{
self.references.insert(*oa, *sa);
}
self.staged.insert(facet.clone());
self.selected = Some(facet);
self.tab = Tab::Facts;
Ok(())
}
}
@@ -1,345 +1,345 @@
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
use super::*;
use crate::data_values::{FieldPathChoice, FieldPathKey};
#[derive(Default)]
pub struct AddressDraft {
pub selected: Option<FieldPathKey>,
pub scope: Option<FieldPathKey>,
pub page: usize,
}
#[derive(Default)]
pub struct RowDraft {
pub expression: String,
pub editor: text_editor::Content,
pub local: Option<Choice>,
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
)
}
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
match facet {
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
}),
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
oa: if layer == Layer::Oa { keys.oa } else { None },
sa: if layer == Layer::Sa { keys.sa } else { None },
}),
_ => facet.clone(),
}
}
impl ReviewRow {
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
if layer == Layer::Oa {
&self.annotation_oa
} else {
&self.annotation_sa
}
}
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
if layer == Layer::Oa {
&mut self.annotation_oa
} else {
&mut self.annotation_sa
}
}
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
let trace = self.effective_trace(project)?;
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
if layer == self.direction.target_layer()
&& let Ok(value) = self.combined_value(project, true)
&& let Ok(element) = serde_json::from_value(value)
{
return Some(element);
}
project.elements.get(&id).cloned()
}
pub(super) fn annotation_choices(
&self,
project: &Project,
layer: Layer,
) -> Result<Vec<FieldPathChoice>, String> {
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
..
},
..
}) = element.shared()
else {
return Err(
"Choose an exchange on this message before selecting a carried field.".into(),
);
};
let exchange = project
.relations
.get(exchange)
.ok_or("The message exchange is unavailable.")?;
crate::data_values::at_scope(
project,
exchange,
message_kind,
self.annotation_address(layer).scope.as_ref(),
)
}
pub(super) fn binding_choices(
&self,
project: &Project,
layer: Layer,
referenced: bool,
) -> Vec<Choice> {
let Some(element) = self.row_owner(project, layer) else {
return vec![];
};
let scenario = if referenced {
match element.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { scenario, .. },
..
}) => *scenario,
_ => None,
}
} else {
element
.parent
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
};
let mut choices: Vec<_> = project
.elements
.values()
.filter(|e| {
e.kind() == Kind::Lifeline
&& e.layer() == layer
&& scenario.is_some()
&& e.parent == scenario
})
.map(|e| view::choice(project, e.id))
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
choices
}
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
let pairs: Vec<_> = match facet {
FacetSelector::Annotation(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => {
if oa.path.len() != sa.path.len() {
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
}
oa.path
.iter()
.chain(std::iter::once(&oa.field))
.copied()
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
.collect()
}
FacetSelector::Binding(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => vec![(*oa, *sa)],
_ => vec![],
};
// A repeated recursive field may occur several times in one address.
// Never let the last occurrence silently replace a conflicting pair.
let mut supplied = BTreeMap::new();
for (oa, sa) in pairs {
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
return Err(
"This address pairs the same Operational field with different System fields."
.into(),
);
}
}
self.references.extend(supplied);
Ok(())
}
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
if self.row_drafts.contains_key(facet) {
return;
}
let value = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
let present = value.as_ref().is_some_and(|v| !v.is_null());
let mut draft = RowDraft::default();
if let Some(value) = value {
draft.expression = value["expression"].as_str().unwrap_or_default().into();
draft.editor = text_editor::Content::with_text(&draft.expression);
draft.local = value["local"]
.as_str()
.and_then(|v| v.parse().ok())
.map(|id| view::choice(project, id));
}
self.presence.entry(facet.clone()).or_insert(present);
self.row_drafts.insert(facet.clone(), draft);
}
pub(super) fn edit_row(
&mut self,
facet: &FacetSelector,
action: impl FnOnce(&mut RowDraft),
) -> Result<(), String> {
if !supported(facet)
|| self.selected.as_ref() != Some(facet)
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select this row and choose Set final value before editing it.".into());
}
action(
self.row_drafts
.get_mut(facet)
.ok_or("The row draft is unavailable.")?,
);
Ok(())
}
pub(super) fn project_row(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if decision == SemanticDecision::KeepTarget {
return Ok(());
}
let source_layer = self.direction.source_layer();
let target_layer = self.direction.target_layer();
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
return Ok(());
}
let target_key = keyed::key(facet, target_layer)
.ok_or("Choose the destination row address before setting or taking its value.")?;
let row = if decision == SemanticDecision::Candidate {
if !self.presence.get(facet).copied().unwrap_or(false) {
Value::Null
} else {
let draft = self
.row_drafts
.get(facet)
.ok_or("Set the final row value.")?;
match facet {
FacetSelector::Annotation(_) => {
let key: AnnotationKey = serde_json::from_value(target_key.clone())
.map_err(|e| e.to_string())?;
serde_json::to_value(MessageValue {
path: key.path,
field: key.field,
expression: draft.expression.clone(),
})
.expect("Message value")
}
FacetSelector::Binding(_) => {
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
}
_ => return Err("Unsupported row.".into()),
}
}
} else {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
let row = scalar::value(&fact, source_layer)
.ok_or("Source row collection is not applicable; review its constructor first.")?;
if row.is_null() {
row
} else {
let map = |id: Id| -> Result<Id, String> {
if source_layer == Layer::Oa {
self.references.get(&id).copied().ok_or_else(|| {
format!("Choose a reference counterpart for {}.", project.named(id))
})
} else {
let matches: Vec<_> = self
.references
.iter()
.filter(|(_, sa)| **sa == id)
.map(|(oa, _)| *oa)
.collect();
if matches.len() == 1 {
Ok(matches[0])
} else {
Err(format!(
"Choose one unambiguous reference counterpart for {}.",
project.named(id)
))
}
}
};
match facet {
FacetSelector::Annotation(_) => {
let mut row: MessageValue =
serde_json::from_value(row).map_err(|e| e.to_string())?;
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
row.field = map(row.field)?;
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
!= target_key
{
return Err("Mapped annotation address disagrees with the selected destination address.".into());
}
serde_json::to_value(row).expect("row")
}
FacetSelector::Binding(_) => {
let row: LifelineBinding =
serde_json::from_value(row).map_err(|e| e.to_string())?;
let mapped = LifelineBinding {
referenced: map(row.referenced)?,
local: map(row.local)?,
};
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
}
serde_json::to_value(mapped).expect("row")
}
_ => return Err("Unsupported row.".into()),
}
}
};
let family = keyed::family(facet).ok_or("Unsupported row.")?;
let path = keyed::path(value, family)
.ok_or("The destination constructor has no collection for this row.")?;
let rows = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination rows are unavailable.")?;
let position = rows.iter().position(|row| match facet {
FacetSelector::Annotation(_) => {
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
}
FacetSelector::Binding(_) => row["referenced"] == target_key,
_ => false,
});
match (position, row.is_null()) {
(Some(index), false) => rows[index] = row,
(Some(index), true) => {
rows.remove(index);
}
(None, false) => rows.push(row),
(None, true) => {}
}
Ok(())
}
}
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
use super::*;
use crate::data_values::{FieldPathChoice, FieldPathKey};
#[derive(Default)]
pub struct AddressDraft {
pub selected: Option<FieldPathKey>,
pub scope: Option<FieldPathKey>,
pub page: usize,
}
#[derive(Default)]
pub struct RowDraft {
pub expression: String,
pub editor: text_editor::Content,
pub local: Option<Choice>,
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
)
}
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
match facet {
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
}),
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
oa: if layer == Layer::Oa { keys.oa } else { None },
sa: if layer == Layer::Sa { keys.sa } else { None },
}),
_ => facet.clone(),
}
}
impl ReviewRow {
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
if layer == Layer::Oa {
&self.annotation_oa
} else {
&self.annotation_sa
}
}
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
if layer == Layer::Oa {
&mut self.annotation_oa
} else {
&mut self.annotation_sa
}
}
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
let trace = self.effective_trace(project)?;
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
if layer == self.direction.target_layer()
&& let Ok(value) = self.combined_value(project, true)
&& let Ok(element) = serde_json::from_value(value)
{
return Some(element);
}
project.elements.get(&id).cloned()
}
pub(super) fn annotation_choices(
&self,
project: &Project,
layer: Layer,
) -> Result<Vec<FieldPathChoice>, String> {
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
..
},
..
}) = element.shared()
else {
return Err(
"Choose an exchange on this message before selecting a carried field.".into(),
);
};
let exchange = project
.relations
.get(exchange)
.ok_or("The message exchange is unavailable.")?;
crate::data_values::at_scope(
project,
exchange,
message_kind,
self.annotation_address(layer).scope.as_ref(),
)
}
pub(super) fn binding_choices(
&self,
project: &Project,
layer: Layer,
referenced: bool,
) -> Vec<Choice> {
let Some(element) = self.row_owner(project, layer) else {
return vec![];
};
let scenario = if referenced {
match element.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { scenario, .. },
..
}) => *scenario,
_ => None,
}
} else {
element
.parent
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
};
let mut choices: Vec<_> = project
.elements
.values()
.filter(|e| {
e.kind() == Kind::Lifeline
&& e.layer() == layer
&& scenario.is_some()
&& e.parent == scenario
})
.map(|e| view::choice(project, e.id))
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
choices
}
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
let pairs: Vec<_> = match facet {
FacetSelector::Annotation(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => {
if oa.path.len() != sa.path.len() {
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
}
oa.path
.iter()
.chain(std::iter::once(&oa.field))
.copied()
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
.collect()
}
FacetSelector::Binding(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => vec![(*oa, *sa)],
_ => vec![],
};
// A repeated recursive field may occur several times in one address.
// Never let the last occurrence silently replace a conflicting pair.
let mut supplied = BTreeMap::new();
for (oa, sa) in pairs {
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
return Err(
"This address pairs the same Operational field with different System fields."
.into(),
);
}
}
self.references.extend(supplied);
Ok(())
}
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
if self.row_drafts.contains_key(facet) {
return;
}
let value = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
let present = value.as_ref().is_some_and(|v| !v.is_null());
let mut draft = RowDraft::default();
if let Some(value) = value {
draft.expression = value["expression"].as_str().unwrap_or_default().into();
draft.editor = text_editor::Content::with_text(&draft.expression);
draft.local = value["local"]
.as_str()
.and_then(|v| v.parse().ok())
.map(|id| view::choice(project, id));
}
self.presence.entry(facet.clone()).or_insert(present);
self.row_drafts.insert(facet.clone(), draft);
}
pub(super) fn edit_row(
&mut self,
facet: &FacetSelector,
action: impl FnOnce(&mut RowDraft),
) -> Result<(), String> {
if !supported(facet)
|| self.selected.as_ref() != Some(facet)
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select this row and choose Set final value before editing it.".into());
}
action(
self.row_drafts
.get_mut(facet)
.ok_or("The row draft is unavailable.")?,
);
Ok(())
}
pub(super) fn project_row(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if decision == SemanticDecision::KeepTarget {
return Ok(());
}
let source_layer = self.direction.source_layer();
let target_layer = self.direction.target_layer();
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
return Ok(());
}
let target_key = keyed::key(facet, target_layer)
.ok_or("Choose the destination row address before setting or taking its value.")?;
let row = if decision == SemanticDecision::Candidate {
if !self.presence.get(facet).copied().unwrap_or(false) {
Value::Null
} else {
let draft = self
.row_drafts
.get(facet)
.ok_or("Set the final row value.")?;
match facet {
FacetSelector::Annotation(_) => {
let key: AnnotationKey = serde_json::from_value(target_key.clone())
.map_err(|e| e.to_string())?;
serde_json::to_value(MessageValue {
path: key.path,
field: key.field,
expression: draft.expression.clone(),
})
.expect("Message value")
}
FacetSelector::Binding(_) => {
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
}
_ => return Err("Unsupported row.".into()),
}
}
} else {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
let row = scalar::value(&fact, source_layer)
.ok_or("Source row collection is not applicable; review its constructor first.")?;
if row.is_null() {
row
} else {
let map = |id: Id| -> Result<Id, String> {
if source_layer == Layer::Oa {
self.references.get(&id).copied().ok_or_else(|| {
format!("Choose a reference counterpart for {}.", project.named(id))
})
} else {
let matches: Vec<_> = self
.references
.iter()
.filter(|(_, sa)| **sa == id)
.map(|(oa, _)| *oa)
.collect();
if matches.len() == 1 {
Ok(matches[0])
} else {
Err(format!(
"Choose one unambiguous reference counterpart for {}.",
project.named(id)
))
}
}
};
match facet {
FacetSelector::Annotation(_) => {
let mut row: MessageValue =
serde_json::from_value(row).map_err(|e| e.to_string())?;
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
row.field = map(row.field)?;
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
!= target_key
{
return Err("Mapped annotation address disagrees with the selected destination address.".into());
}
serde_json::to_value(row).expect("row")
}
FacetSelector::Binding(_) => {
let row: LifelineBinding =
serde_json::from_value(row).map_err(|e| e.to_string())?;
let mapped = LifelineBinding {
referenced: map(row.referenced)?,
local: map(row.local)?,
};
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
}
serde_json::to_value(mapped).expect("row")
}
_ => return Err("Unsupported row.".into()),
}
}
};
let family = keyed::family(facet).ok_or("Unsupported row.")?;
let path = keyed::path(value, family)
.ok_or("The destination constructor has no collection for this row.")?;
let rows = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination rows are unavailable.")?;
let position = rows.iter().position(|row| match facet {
FacetSelector::Annotation(_) => {
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
}
FacetSelector::Binding(_) => row["referenced"] == target_key,
_ => false,
});
match (position, row.is_null()) {
(Some(index), false) => rows[index] = row,
(Some(index), true) => {
rows.remove(index);
}
(None, false) => rows.push(row),
(None, true) => {}
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+254 -254
View File
@@ -1,254 +1,254 @@
//! Independent membership and complete final order; the core validates every proposal.
use super::*;
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
match which {
OrderedCollection::Steps => ReferenceCollection::Steps,
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
}
}
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
match which {
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
_ => None,
}
}
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
}
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
Ok(())
}
impl ReviewRow {
pub(super) fn ordered_members(
&self,
project: &Project,
which: OrderedCollection,
) -> Result<Vec<Id>, String> {
read(&self.combine(project, true, true)?, which)
}
pub(super) fn initialize_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
if !self.order_drafts.contains_key(&which) {
let ids = self.ordered_members(project, which)?;
self.order_drafts.insert(which, ids);
}
Ok(())
}
pub(super) fn reconcile_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let members = self.ordered_members(project, which)?;
let draft = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
// This is an explicit user action. Ordinary refresh never discards an authored order.
let mut seen = BTreeSet::new();
draft.retain(|id| members.contains(id) && seen.insert(*id));
for id in members {
if seen.insert(id) {
draft.push(id);
}
}
Ok(())
}
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
let facet = FacetSelector::Order(which);
if self.selected.as_ref() != Some(&facet)
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select Set final value for this order before changing it.".into());
}
Ok(())
}
pub(super) fn move_order(
&mut self,
which: OrderedCollection,
index: usize,
delta: i32,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let ids = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
let destination = match delta {
-1 => index.checked_sub(1),
1 => index.checked_add(1),
_ => None,
};
let destination = destination
.filter(|i| index < ids.len() && *i < ids.len())
.ok_or("Choose an existing adjacent position in the final order.")?;
ids.swap(index, destination);
Ok(())
}
pub(super) fn project_order(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
which: OrderedCollection,
decision: SemanticDecision,
) -> Result<(), String> {
let current = read(value, which)?;
let supplied = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::Candidate => self
.order_drafts
.get(&which)
.cloned()
.ok_or("Choose a final order.")?,
SemanticDecision::TakeSource => {
let fact =
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("The source has no applicable order.")?,
)
.map_err(|e| e.to_string())?;
let map = self.order_reference_map()?;
source
.into_iter()
.map(|id| {
map.get(&id).copied().ok_or_else(|| {
format!(
"Pair source order member {} explicitly in Reference pairs.",
project.named(id)
)
})
})
.collect::<Result<Vec<_>, _>>()?
}
};
let actual: BTreeSet<_> = current.iter().copied().collect();
let desired: BTreeSet<_> = supplied.iter().copied().collect();
if desired.len() != supplied.len() {
return Err(
"The final order contains a duplicate identity; reconcile its members explicitly."
.into(),
);
}
if desired != actual {
if decision == SemanticDecision::TakeSource
&& desired.is_subset(&actual)
&& current
.iter()
.filter(|id| desired.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
}
write(value, which, supplied)
}
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (a, b) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(a, b).is_some_and(|old| old != b) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
Ok(map)
}
pub(super) fn project_ordered_member(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
form_context: bool,
) -> Result<(), String> {
let FacetSelector::Member {
collection: member_collection,
keys,
} = facet
else {
unreachable!()
};
let which = ordered(*member_collection).expect("Ordered member");
let before = read(value, which)?;
self.keyed_value(project, trace, value, facet, decision)?;
let after = read(value, which)?;
let explicit = self
.decisions
.get(&FacetSelector::Order(which))
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
return Ok(());
}
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
(keys.oa, keys.sa)
} else {
(keys.sa, keys.oa)
};
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
return Ok(());
};
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("Source order is unavailable.")?,
)
.map_err(|e| e.to_string())?;
let position = source
.iter()
.position(|id| *id == source_key)
.ok_or("Source member is absent.")?;
let map = self.order_reference_map()?;
let anchor = |id: &Id| {
map.get(id)
.and_then(|mapped| before.iter().position(|id| id == mapped))
};
let left = source[..position].iter().rev().find_map(anchor);
let right = source[position + 1..].iter().find_map(anchor);
let at = match (left, right) {
(Some(a), Some(b)) if a + 1 == b => Some(b),
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
(None, Some(0)) => Some(0),
(None, None) if before.is_empty() => Some(0),
_ => None,
};
if let Some(at) = at {
let mut ordered = before;
ordered.insert(at, target_key);
write(value, which, ordered)
} else if form_context {
// An uncommitted membership list lets the user author a final order.
// The actual request still rejects ambiguous placement until explicitly decided.
Ok(())
} else {
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
}
}
}
//! Independent membership and complete final order; the core validates every proposal.
use super::*;
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
match which {
OrderedCollection::Steps => ReferenceCollection::Steps,
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
}
}
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
match which {
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
_ => None,
}
}
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
}
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
Ok(())
}
impl ReviewRow {
pub(super) fn ordered_members(
&self,
project: &Project,
which: OrderedCollection,
) -> Result<Vec<Id>, String> {
read(&self.combine(project, true, true)?, which)
}
pub(super) fn initialize_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
if !self.order_drafts.contains_key(&which) {
let ids = self.ordered_members(project, which)?;
self.order_drafts.insert(which, ids);
}
Ok(())
}
pub(super) fn reconcile_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let members = self.ordered_members(project, which)?;
let draft = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
// This is an explicit user action. Ordinary refresh never discards an authored order.
let mut seen = BTreeSet::new();
draft.retain(|id| members.contains(id) && seen.insert(*id));
for id in members {
if seen.insert(id) {
draft.push(id);
}
}
Ok(())
}
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
let facet = FacetSelector::Order(which);
if self.selected.as_ref() != Some(&facet)
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select Set final value for this order before changing it.".into());
}
Ok(())
}
pub(super) fn move_order(
&mut self,
which: OrderedCollection,
index: usize,
delta: i32,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let ids = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
let destination = match delta {
-1 => index.checked_sub(1),
1 => index.checked_add(1),
_ => None,
};
let destination = destination
.filter(|i| index < ids.len() && *i < ids.len())
.ok_or("Choose an existing adjacent position in the final order.")?;
ids.swap(index, destination);
Ok(())
}
pub(super) fn project_order(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
which: OrderedCollection,
decision: SemanticDecision,
) -> Result<(), String> {
let current = read(value, which)?;
let supplied = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::Candidate => self
.order_drafts
.get(&which)
.cloned()
.ok_or("Choose a final order.")?,
SemanticDecision::TakeSource => {
let fact =
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("The source has no applicable order.")?,
)
.map_err(|e| e.to_string())?;
let map = self.order_reference_map()?;
source
.into_iter()
.map(|id| {
map.get(&id).copied().ok_or_else(|| {
format!(
"Pair source order member {} explicitly in Reference pairs.",
project.named(id)
)
})
})
.collect::<Result<Vec<_>, _>>()?
}
};
let actual: BTreeSet<_> = current.iter().copied().collect();
let desired: BTreeSet<_> = supplied.iter().copied().collect();
if desired.len() != supplied.len() {
return Err(
"The final order contains a duplicate identity; reconcile its members explicitly."
.into(),
);
}
if desired != actual {
if decision == SemanticDecision::TakeSource
&& desired.is_subset(&actual)
&& current
.iter()
.filter(|id| desired.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
}
write(value, which, supplied)
}
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (a, b) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(a, b).is_some_and(|old| old != b) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
Ok(map)
}
pub(super) fn project_ordered_member(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
form_context: bool,
) -> Result<(), String> {
let FacetSelector::Member {
collection: member_collection,
keys,
} = facet
else {
unreachable!()
};
let which = ordered(*member_collection).expect("Ordered member");
let before = read(value, which)?;
self.keyed_value(project, trace, value, facet, decision)?;
let after = read(value, which)?;
let explicit = self
.decisions
.get(&FacetSelector::Order(which))
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
return Ok(());
}
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
(keys.oa, keys.sa)
} else {
(keys.sa, keys.oa)
};
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
return Ok(());
};
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("Source order is unavailable.")?,
)
.map_err(|e| e.to_string())?;
let position = source
.iter()
.position(|id| *id == source_key)
.ok_or("Source member is absent.")?;
let map = self.order_reference_map()?;
let anchor = |id: &Id| {
map.get(id)
.and_then(|mapped| before.iter().position(|id| id == mapped))
};
let left = source[..position].iter().rev().find_map(anchor);
let right = source[position + 1..].iter().find_map(anchor);
let at = match (left, right) {
(Some(a), Some(b)) if a + 1 == b => Some(b),
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
(None, Some(0)) => Some(0),
(None, None) if before.is_empty() => Some(0),
_ => None,
};
if let Some(at) = at {
let mut ordered = before;
ordered.insert(at, target_key);
write(value, which, ordered)
} else if form_context {
// An uncommitted membership list lets the user author a final order.
// The actual request still rejects ambiguous placement until explicitly decided.
Ok(())
} else {
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
}
}
}
+1 -30
View File
@@ -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> {
+10 -49
View File
@@ -284,7 +284,7 @@ pub fn view(app: &App) -> View<'_, Message> {
)
.padding([4, 16])
.style(style::panel);
let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
return modal_width(
root,
@@ -343,8 +343,8 @@ pub fn view(app: &App) -> View<'_, Message> {
.into(),
);
}
if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) {
return modal(root,column![text("Delete from model?").size(21),text(format!("{} and its owned elements will be removed. Incident relationships, occurrences and view placements are updated together. Counterparts in the other layer remain.",p.named(target.snapshot.request.object))).size(13),label("Undo restores the complete change. Use Hide for diagram-only removal."),row![action("Cancel",Message::CancelDelete),button("Delete from model").style(button::danger).padding(10).on_press(Message::ConfirmDelete(target.snapshot.token))].spacing(10)].spacing(16).into());
if app.delete_pending {
return modal(root,column![text("Delete from model?").size(21),text(format!("{} and its owned elements will be removed. Incident relationships, occurrences and view placements are updated together. Counterparts in the other layer remain.",p.named(app.selected))).size(13),label("Undo restores the complete change. Use Hide for diagram-only removal."),row![action("Cancel",Message::CancelDelete),button("Delete from model").style(button::danger).padding(10).on_press(Message::ConfirmDelete)].spacing(10)].spacing(16).into());
}
if app.pending.is_some() {
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
@@ -414,7 +414,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.align_y(iced::Center);
if let Some(id) = app.active_diagram {
chooser = chooser.push(action("View properties", Message::Select(id)));
controls = controls.push(action("Auto-layout", Message::Command(Command::Layout(id))));
controls = controls.push(action("Arrange", Message::Command(Command::Layout(id))));
}
let toolbar = column![chooser, controls].spacing(6);
let projection = app
@@ -442,7 +442,6 @@ fn diagram(app: &App) -> View<'_, Message> {
.align_y(iced::Center);
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
let program = DiagramCanvas {
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
diagram: id,
@@ -459,31 +458,18 @@ fn diagram(app: &App) -> View<'_, Message> {
connection: app.connection.as_ref(),
};
let mut scene = widget::stack![
crate::diagram::focus::wrap(
widget::canvas(program).width(Fill).height(Fill),
program.context,
crate::app::diagram_removal::enabled(app),
Message::DiagramTraverse
),
widget::canvas(program).width(Fill).height(Fill),
widget::canvas(crate::diagram::PortLabels(program))
.width(Fill)
.height(Fill),
widget::canvas(crate::diagram::WorkFeedback(program))
.width(Fill)
.height(Fill),
];
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
scene = scene.push(editor);
}
row![
widget::sensor(
container(scene)
.id(crate::allocation_input::CANVAS_ID)
.width(Fill)
.height(Fill)
)
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized),
widget::sensor(scene)
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized),
crate::app::architecture_authoring::palette(app)
]
.height(Fill)
@@ -514,27 +500,6 @@ fn diagram(app: &App) -> View<'_, Message> {
if app.active_diagram.is_some() {
tools = tools.push(modes);
}
if projection == Some(ProjectionKind::Allocation) {
tools = tools.push(
row![
action(
&format!(
"{} · {}",
if app.allocation_drag.arrange_next {
"Arrange next drag: armed"
} else {
"Arrange next drag"
},
crate::allocation_input::ARRANGE_SHORTCUT
),
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext)
),
label("Drag work into a performer to assign · out to remove assignment")
]
.spacing(8)
.align_y(iced::Center),
);
}
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
@@ -546,9 +511,7 @@ fn diagram(app: &App) -> View<'_, Message> {
)
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
});
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
feedback.text.as_str()
} else if let Some(session) = &app.connection {
let hint = if let Some(session) = &app.connection {
if session.source.is_some() {
if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
@@ -565,9 +528,7 @@ fn diagram(app: &App) -> View<'_, Message> {
} else {
"Right-click for actions · drag to arrange · scroll to zoom"
};
// Status is outside the measured canvas. Clipping prevents long text
// from painting over work headers or frozen target labels below it.
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
tools = tools.push(container(label(hint)).height(30));
}
column![container(tools).padding(14), canvas].into()
}
+1 -6
View File
@@ -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 -2
View File
@@ -2,8 +2,7 @@
Run from any directory with Python 3: semantic_schema.py [--check] [--validate].
--validate additionally runs source-7 structural fixtures with jsonschema.
The desktop stored-history editor reads the checked-in descriptor for field
shapes. Core types and validation remain authoritative for model mutations.
This generates documentation only, never runtime selectors or model mutations.
"""
import copy
import json
-17
View File
@@ -2037,23 +2037,6 @@ will occur. Reparenting work must not silently change its performer. A computed
parent summary must remain distinguishable from direct allocation. A collapse
operation hides presentation detail without deleting the children.
In an Allocation view, dragging an Activity or Function body MAY combine a
performer change with diagram placement. The preview MUST identify the
destination and preserve readable work and performer labels. A clean click
selects without applying unrelated drafts. A refused work press with an unapplied
property draft MUST preserve its text, caret and focused editor. An accepted drag is one atomic
model/source/history transaction: semantic ownership and work identities are
preserved; only leaf direct Allocation relations, their own retired routes and
the affected view geometry change. Existing Allocation identities are retained on reassignment. Role
realizations and assignments MUST NOT be silently changed. Uniform work groups
may move their leaf responsibilities together; ambiguous or distributed groups
and changes requiring collateral record edits MUST be refused with an explanation.
Moving outside performer regions removes direct assignment only when the
effective role responsibility is also absent. Releasing outside the viewport
cancels the gesture. Explicit Arrange next drag applies geometry alone for one
gesture. Refused and snapped no-op drops MUST NOT create history entries or
discard Redo. Undo restores the exact source bundle and identities together.
Applying a reachable source bundle is one model transaction. Graphical edits
update its owning declarations through the source-preserving updater. Both
paths validate their candidate and model/source equality before
+12837 -12837
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ Graphical and textual parity applies to the persistent fields of this profile. D
- OA captures operational needs; SA defines the selected system contribution. SA contains no internal logical/physical components, and Studio does not decompose SA Actors. The System may be absent while authoring, with a completeness warning.
- Work decomposition and performer allocation are independent. Only leaves have direct allocations. A parent drawn within one performer can be a computed leaf summary, not an additional allocation. Role sharing does not create several direct performers for one function.
- Nesting expresses semantic containment or the selected allocation projection. A placement edit preserves ownership and allocation. In an Allocation view, a work-body drag previews and commits performer reassignment and placement as one transaction; Arrange next drag (Alt+A) keeps that gesture limited to geometry. Role responsibilities remain unchanged, and drops requiring ambiguous responsibility or collateral edits are refused. Hide and delete have different effects. Current verification and native limits are recorded in [issue 3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3).
- Nesting expresses semantic containment or the selected allocation projection. Placement movement does not change ownership or allocation. Hide and delete have different effects.
- Counterpart creation copies mapped typed content and required references. Existing-counterpart semantic review compares typed facts and prior observations, with explicit keep, take or final-value decisions for supported scalar, structural, constructor and individual collection-row changes. Reference mapping and candidate validation apply before an undoable transaction. Ordered collections have explicit final-order review with retained rejected drafts. Included trace rows can combine multiple source contributions into one validated model/history transaction; conflicting decisions and incompatible reference mappings remain explicit blockers. Existing Child/Incident attachment fields and artifact lifecycle review are implemented. Artifact creation reserves an explicit counterpart identity and requires all seven metadata decisions; identified source-owner absence can delete a link, while destination-only Take retains it. Owner moves and kept Presence must agree. Latest attachment/artifact native acceptance remains pending. Review does not automatically synchronize arbitrary structure or overwrite the other perspective. Creation scope follows ownership/reference dependencies, not every incident relationship.
- Processes/chains use contributions and explicit sequence links; imported ordered exchange paths retain their implied order until topology conversion. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile.
- OA Communication means connect Entities/Actors directly and can allocate Interactions whose actual performers match their endpoints and direction. Transition to SA requires explicit compatible boundary-port decisions; the tool does not guess system ports. Preserved OA boundary exchanges use Legacy ports. Capability grouping is containment; include, extend and generalization use separate typed relationships in OA and SA. Data/item references remain within their layer and can have explicit transitioned counterparts.

Some files were not shown because too many files have changed in this diff Show More