Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95d031048 | ||
|
|
9e51cd72ba | ||
|
|
3c22fe2629 | ||
|
|
cccd66c5d5 | ||
|
|
355f6d4d24 | ||
|
|
357da24542 | ||
|
|
44095f78c2 | ||
|
|
0ecdadbb9c | ||
|
|
c28abcf13a | ||
|
|
0d8c144355 | ||
|
|
7f2947d651 | ||
|
|
3412d4f369 | ||
|
|
5e62d88c4f | ||
|
|
13f7e6b0d1 | ||
|
|
29525d7823 | ||
|
|
0f002b4254 | ||
|
|
3ac74825f8 | ||
|
|
64fea0a505 | ||
|
|
e1240295bd | ||
|
|
659b7a655a | ||
|
|
a17ca5d066 | ||
|
|
95e0e12716 | ||
|
|
dd39e1fdc1 | ||
|
|
f555193172 | ||
|
|
e44f07565c | ||
|
|
a357b22778 | ||
|
|
05b613ea19 | ||
|
|
afa74a3059 | ||
|
|
200aa90689 | ||
|
|
2c6ededc51 | ||
|
|
bbd3b0e843 | ||
|
|
8c3e7acd14 | ||
|
|
26ca4a4c81 | ||
|
|
aadc6396d3 | ||
|
|
e0685b8e52 | ||
|
|
d61f1f3877 | ||
|
|
419c78c071 | ||
|
|
83f53c7c6e | ||
|
|
40154983cc | ||
|
|
8112dea7a3 | ||
|
|
e2c027e54c | ||
|
|
b25065c442 | ||
|
|
956a584132 | ||
|
|
e57c390d49 | ||
|
|
24bb24839b | ||
|
|
f7d84faf30 | ||
|
|
260d773852 | ||
|
|
42511f0f8a | ||
|
|
c3c2d01453 | ||
|
|
4c387ccd64 | ||
|
|
cb46ef4054 | ||
|
|
2d9e9c1849 | ||
|
|
12b2237ee9 | ||
|
|
49a4cb17b1 | ||
|
|
28e21a0ae8 | ||
|
|
dc1506f2e8 | ||
|
|
5b174a9fa6 | ||
|
|
0d6f677329 | ||
|
|
60ea483430 | ||
|
|
0c38980c70 | ||
|
|
f1bd5149db | ||
|
|
3840ca2965 | ||
|
|
5ce798b713 | ||
|
|
1b3c401a19 | ||
|
|
76209cc186 | ||
|
|
5a7018117d | ||
|
|
85173a3c95 | ||
|
|
d9b3f0dc91 | ||
|
|
94b5eecb1c | ||
|
|
6b9cc77ece | ||
|
|
bc8bc4f5a1 | ||
|
|
f52d81c1b9 | ||
|
|
97c91cc06c | ||
|
|
0bd7886608 | ||
|
|
563772dc62 | ||
|
|
eefdedaf47 | ||
|
|
bc88acd932 | ||
|
|
17d56c06f7 | ||
|
|
bcb7057ab2 | ||
|
|
36eff7225a | ||
|
|
dd38c46a4b | ||
|
|
a31b40d0f8 | ||
|
|
88c17a448f | ||
|
|
abffa4b3f0 | ||
|
|
82fd0acf64 | ||
|
|
ff0175488a | ||
|
|
06ec962129 | ||
|
|
6969c825a8 | ||
|
|
638d06c4e3 | ||
|
|
41fa5d530c | ||
|
|
828c50c491 | ||
|
|
f8a6a5264a | ||
|
|
b4c8fd7102 | ||
|
|
e7491fb00a | ||
|
|
bef1ea33e4 | ||
|
|
3c9e6eeb64 | ||
|
|
80f2588214 | ||
|
|
8895dd2e0f | ||
|
|
8d55d2d2eb | ||
|
|
03d20ec296 | ||
|
|
c0e66f94e3 | ||
|
|
7f081ad457 | ||
|
|
c7ed894900 | ||
|
|
98427b9620 | ||
|
|
cab01c3d7f | ||
|
|
d8bdbd9e48 | ||
|
|
7d14bd0dd1 | ||
|
|
dc0f2e12f3 | ||
|
|
bc311bc076 | ||
|
|
a34558d3be | ||
|
|
2cc4d115d0 | ||
|
|
1e9f0c2a97 | ||
|
|
1516949567 | ||
|
|
c4e1580bb0 |
+50
-48
@@ -22,65 +22,67 @@ 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
|
||||
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=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/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/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-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/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
|
||||
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
|
||||
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
|
||||
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
|
||||
|
||||
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
|
||||
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
|
||||
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
|
||||
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
|
||||
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
|
||||
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
|
||||
harness/release-native-child-continuation-review-20260911.md -text -whitespace
|
||||
|
||||
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
|
||||
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
|
||||
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
|
||||
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
|
||||
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
|
||||
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
|
||||
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
|
||||
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
|
||||
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
|
||||
harness/experiments/allocation-3840ca2-pause-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-removal-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-resize-foundation-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/delete-native-f7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-editing-followup-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/resize-port-policy-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-83f53c7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/nested-lifecycle-union-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-d61f1f3-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/lifecycle-fixture-preflight-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/palette-readability-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/release-analysis-next-acceptance-20260913.md -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/runtime-notices-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-checked-save-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-dd39e1f-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-palette-e124029-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-native-preparation-e124029-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source-s0-s1-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol
|
||||
harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
Generated
+22
@@ -2107,6 +2107,25 @@ 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"
|
||||
@@ -2153,6 +2172,9 @@ name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -12,6 +12,9 @@ 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"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
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,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
//! 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
|
||||
}
|
||||
#[cfg(test)]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TargetPresentation {
|
||||
pub placement: Id,
|
||||
}
|
||||
|
||||
#[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("A visible accepted performer target is required.".into())
|
||||
})?;
|
||||
let node = diagram
|
||||
.nodes
|
||||
.get_mut(&target.placement)
|
||||
.ok_or(Refusal::StaleScope)?;
|
||||
let visible_target = plan
|
||||
.before_visible
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == target.placement && n.element == performer);
|
||||
if node.element != performer || !visible_target {
|
||||
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(),
|
||||
));
|
||||
}
|
||||
// Destination identity lives in a fixed toolbar slot. Frozen hit geometry
|
||||
// and its outline never introduce an opaque label over this work header.
|
||||
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
@@ -0,0 +1,267 @@
|
||||
//! 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,
|
||||
navigation_focus: None,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+209
-123
@@ -1,7 +1,15 @@
|
||||
pub(crate) mod allocation_drag;
|
||||
pub(crate) mod architecture_authoring;
|
||||
#[cfg(test)]
|
||||
mod architecture_authoring_tests;
|
||||
#[cfg(test)]
|
||||
mod behavior_removal_tests;
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_focus_tests;
|
||||
pub(crate) mod diagram_removal;
|
||||
#[cfg(test)]
|
||||
mod diagram_removal_tests;
|
||||
mod diagram_reveal;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
@@ -10,6 +18,7 @@ mod hosted;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
@@ -330,6 +339,7 @@ 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),
|
||||
@@ -394,7 +404,9 @@ pub enum Message {
|
||||
RelationKind(RelationKind),
|
||||
Command(Command),
|
||||
Delete,
|
||||
ConfirmDelete,
|
||||
ConfirmDelete(u64),
|
||||
DiagramRemove(diagram_removal::Intent),
|
||||
DiagramTraverse(bool),
|
||||
CancelDelete,
|
||||
Undo,
|
||||
Redo,
|
||||
@@ -436,6 +448,8 @@ pub enum Message {
|
||||
crate::source_navigation::SymbolEvent,
|
||||
),
|
||||
SourceSymbolsReady(crate::source_navigation::Completion),
|
||||
SourceRename(source_rename::Event),
|
||||
SourceRenameReady(source_rename::Completion),
|
||||
DiagramUsage(crate::diagram_usage::Event),
|
||||
DiagramUsagesReady(crate::diagram_usage::Completion),
|
||||
DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size),
|
||||
@@ -462,6 +476,7 @@ 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>,
|
||||
@@ -501,6 +516,7 @@ 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>,
|
||||
@@ -521,6 +537,7 @@ pub struct App {
|
||||
pub source_navigation: crate::source_navigation::State,
|
||||
source_find_instance: Id,
|
||||
pub source_symbols: crate::source_navigation::SymbolState,
|
||||
pub source_rename: source_rename::State,
|
||||
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
|
||||
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
|
||||
source_diagram_job: Option<Id>,
|
||||
@@ -580,6 +597,7 @@ 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,
|
||||
@@ -621,6 +639,7 @@ impl App {
|
||||
busy: false,
|
||||
pending: None,
|
||||
delete_pending: false,
|
||||
delete_target: None,
|
||||
path: None,
|
||||
pending_recovery: None,
|
||||
disk_project: None,
|
||||
@@ -640,6 +659,7 @@ impl App {
|
||||
source_navigation: crate::source_navigation::State::default(),
|
||||
source_find_instance: Id::new_v4(),
|
||||
source_symbols: crate::source_navigation::SymbolState::default(),
|
||||
source_rename: source_rename::State::default(),
|
||||
diagram_usages: None,
|
||||
diagram_navigation_focus: None,
|
||||
source_diagram_job: None,
|
||||
@@ -691,19 +711,37 @@ impl App {
|
||||
|| self.behavior.has_pending()
|
||||
}
|
||||
fn sync_source(&mut self) {
|
||||
self.sync_source_with_alias_editor(None);
|
||||
}
|
||||
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.source_workspace.accept(self.document.bundle());
|
||||
self.show_source_file(false);
|
||||
self.show_source_file_with_alias_editor(false, hint);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.show_source_file_with_alias_editor(restore_cursor, None);
|
||||
}
|
||||
fn show_source_file_with_alias_editor(
|
||||
&mut self,
|
||||
restore_cursor: bool,
|
||||
hint: Option<source_rename::EditorRefresh<'_>>,
|
||||
) {
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
self.source_select_line = false;
|
||||
let source = self.source_workspace.text().to_owned();
|
||||
self.source_navigation.refresh(&source);
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
if !hint.is_some_and(|hint| {
|
||||
hint.apply(
|
||||
&mut self.source_editor,
|
||||
&self.source_workspace.active,
|
||||
&source,
|
||||
)
|
||||
}) {
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
}
|
||||
self.source_snapshot = Arc::from(source);
|
||||
self.source_jump = restore_cursor
|
||||
.then(|| {
|
||||
@@ -748,6 +786,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn sync(&mut self) {
|
||||
self.sync_with_alias_editor(None);
|
||||
}
|
||||
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.model_issues = validate(self.project());
|
||||
self.normalize_creation();
|
||||
@@ -777,7 +818,7 @@ impl App {
|
||||
.map(|d| d.id);
|
||||
}
|
||||
self.refresh_draft();
|
||||
self.sync_source();
|
||||
self.sync_source_with_alias_editor(hint);
|
||||
}
|
||||
fn normalize_creation(&mut self) {
|
||||
if !self.create_kind.supports(self.layer) {
|
||||
@@ -970,6 +1011,7 @@ impl App {
|
||||
let source_version = self.source_version + 1;
|
||||
let lint_running = self.lint_running;
|
||||
let navigation_in_flight = self.source_symbols.in_flight;
|
||||
let rename_in_flight = self.source_rename.in_flight;
|
||||
let diagram_lookup_in_flight = self.diagram_lookup_in_flight;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
@@ -983,6 +1025,7 @@ impl App {
|
||||
self.source_version = source_version;
|
||||
self.lint_running = lint_running;
|
||||
self.source_symbols.in_flight = navigation_in_flight;
|
||||
self.source_rename.in_flight = rename_in_flight;
|
||||
self.diagram_lookup_in_flight = diagram_lookup_in_flight;
|
||||
self.guide = guide;
|
||||
self.path = path;
|
||||
@@ -1139,6 +1182,13 @@ 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))
|
||||
}),
|
||||
// Use one captured-Escape bridge: a focused Find input consumes
|
||||
// Escape, while modal priority remains in the ordinary handler.
|
||||
behavior::creation::subscription(
|
||||
@@ -1146,11 +1196,16 @@ impl App {
|
||||
|| self.diagram_usages.is_some()
|
||||
|| self.behavior.source_active()
|
||||
|| (self.page == Page::Text
|
||||
&& (self.source_symbols.open || self.source_navigation.open)
|
||||
&& (self.source_symbols.open
|
||||
|| self.source_navigation.open
|
||||
|| self.source_rename.open())
|
||||
&& self.architecture.is_none()),
|
||||
)
|
||||
.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,
|
||||
@@ -1194,6 +1249,35 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// These handlers reject old owners/tokens without changing state. Apply
|
||||
// the same admission before shared camera and allocation boundaries.
|
||||
if !self.source_input_envelope(&message)
|
||||
|| matches!(&message, Message::DiagramUsage(event) if !self.diagram_usage_envelope(event))
|
||||
|| matches!(&message, Message::WorkDrag(event) if !self.allocation_event_envelope(event))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::WorkDrag(allocation_drag::Event::Press(press)) = &message
|
||||
&& !self.allocation_press_owns_current_view(press)
|
||||
{
|
||||
// Keep the existing stale-press explanation, without treating the
|
||||
// rejected press as a new navigation or allocation boundary.
|
||||
return self.allocation_event(allocation_drag::Event::Press(press.clone()));
|
||||
}
|
||||
// A queued toolbar from a previous input owner cannot retire a newer
|
||||
// navigation destination or allocation capability before dispatch.
|
||||
if let Message::BehaviorControl(intent) = &message
|
||||
&& behavior::canvas_workspace::Scope::input_owner(self) != Some(intent.scope)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Reject stale rename intents before camera/reveal reconciliation or
|
||||
// source interaction can disturb a newer navigation destination.
|
||||
if let Message::SourceRename(event) = &message
|
||||
&& !self.source_rename_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let usage_viewport = self.diagram_reveal_viewport(&message);
|
||||
let window_resized = matches!(&message, Message::WindowResized(_));
|
||||
// Reject stale measurements before legacy viewport handlers can change
|
||||
@@ -1205,6 +1289,11 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
self.reconcile_pending_diagram_reveal(&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);
|
||||
}
|
||||
@@ -1215,6 +1304,9 @@ impl App {
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let task = self.update_inner(message);
|
||||
if self.allocation_boundary() != allocation_before {
|
||||
self.allocation_retire();
|
||||
}
|
||||
if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal {
|
||||
*epoch = self.canvas_epoch;
|
||||
}
|
||||
@@ -1258,27 +1350,14 @@ impl App {
|
||||
// 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, _),
|
||||
Scenario::Zoom(c, _) | Scenario::Choose(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) => {
|
||||
Message::TopologyCanvas(Process::Zoom(c, _) | Process::Choose(c, _))
|
||||
if valid_process(*c) =>
|
||||
{
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_)))
|
||||
@@ -1317,35 +1396,25 @@ impl App {
|
||||
)
|
||||
}
|
||||
fn update_inner(&mut self, message: Message) -> Task<Message> {
|
||||
if !self.source_input_envelope(&message) {
|
||||
return Task::none();
|
||||
}
|
||||
let message = match message {
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
if context != self.source_symbol_context()
|
||||
|| instance != self.source_find_instance
|
||||
|| !self.source_navigation.open
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
Message::SourceFindClose(..) => {
|
||||
// Only the checked envelope may enter the legacy Close handler.
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close)
|
||||
}
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
|
||||
return Task::none();
|
||||
}
|
||||
message => message,
|
||||
};
|
||||
if let Message::SourceSymbols(context, _) = &message
|
||||
&& (context.session != self.source_workspace.session()
|
||||
|| context.path != self.source_workspace.active)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Internal results and geometry do not constitute a new user intent.
|
||||
// Process query completion even behind a modal to release its worker slot;
|
||||
// its handler independently refuses navigation outside the live editor.
|
||||
if let Message::SourceSymbolsReady(completion) = message {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
if let Message::SourceRenameReady(completion) = message {
|
||||
return self.source_rename_ready(completion);
|
||||
}
|
||||
if let Message::DiagramUsagesReady(completion) = message {
|
||||
return self.diagram_usages_ready(completion);
|
||||
}
|
||||
@@ -1523,6 +1592,8 @@ 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() {
|
||||
@@ -1557,7 +1628,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!(
|
||||
@@ -1567,6 +1638,18 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_rename.open() {
|
||||
if matches!(
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.close_source_rename();
|
||||
return Task::none();
|
||||
}
|
||||
if !modal_event && !matches!(message, Message::SourceRename(_)) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
@@ -1611,6 +1694,7 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.refresh_source_rename_context();
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
@@ -1695,6 +1779,7 @@ impl App {
|
||||
}
|
||||
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),
|
||||
@@ -1785,8 +1870,7 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
@@ -1807,9 +1891,19 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
CanvasEvent::Focus(_) => {
|
||||
self.behavior.scenario.toggle_focus();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
CanvasEvent::Remove(_, id) => {
|
||||
if diagram_removal::enabled(self)
|
||||
&& self.selected == id
|
||||
&& self.behavior.scenario.canvas_tool(context.scenario)
|
||||
== crate::behavior::scenario::CanvasTool::Select
|
||||
&& crate::behavior::scenario::removal_target(
|
||||
self.project(),
|
||||
context.scenario,
|
||||
id,
|
||||
)
|
||||
{
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Shelf(_, shelf) => {
|
||||
self.behavior.scenario.toggle_shelf(shelf);
|
||||
@@ -2165,10 +2259,16 @@ impl App {
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
self.source_navigation.open = false;
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
if restore_source_focus && self.source_navigation.open {
|
||||
// Dismiss the active Find surface first. Its underlying
|
||||
// references remain available until a separate Escape.
|
||||
self.source_navigation.open = false;
|
||||
} else {
|
||||
self.source_navigation.open = false;
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
}
|
||||
if restore_source_focus {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
@@ -2942,19 +3042,25 @@ 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.delete_pending = true;
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
Message::ConfirmDelete => {
|
||||
if !self.delete_pending || self.behavior.has_pending() {
|
||||
return Task::none();
|
||||
}
|
||||
Message::ConfirmDelete(token) => self.confirm_model_delete(token),
|
||||
Message::CancelDelete => {
|
||||
self.delete_pending = false;
|
||||
self.execute(Command::Delete(self.selected));
|
||||
self.delete_target = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Message::CancelDelete => self.delete_pending = false,
|
||||
Message::Undo | Message::Redo => {
|
||||
let redo = matches!(message, Message::Redo);
|
||||
if self.apply() {
|
||||
@@ -3161,10 +3267,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::SourceFile(path) => {
|
||||
if self
|
||||
.source_workspace
|
||||
.switch(&path, self.source_editor.cursor())
|
||||
if path != self.source_workspace.active
|
||||
&& self.source_workspace.files.contains_key(&path)
|
||||
{
|
||||
let departure =
|
||||
crate::source_navigation::selection::capture(&mut self.source_editor);
|
||||
self.source_workspace.switch(&path, departure);
|
||||
self.show_source_file(true);
|
||||
}
|
||||
}
|
||||
@@ -3210,6 +3318,7 @@ impl App {
|
||||
if self.pending_recovery.as_ref() != Some(&receipt.target) {
|
||||
return Task::none();
|
||||
}
|
||||
self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again.");
|
||||
self.pending_recovery = None;
|
||||
self.source_diagnostics
|
||||
.retain(|issue| issue.diagnostic.code != "pending_save");
|
||||
@@ -3297,6 +3406,8 @@ impl App {
|
||||
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
|
||||
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
|
||||
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
|
||||
Message::SourceRename(event) => return self.source_rename_event(event),
|
||||
Message::SourceRenameReady(completion) => return self.source_rename_ready(completion),
|
||||
Message::SourceSymbolsReady(completion) => {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
@@ -3694,52 +3805,6 @@ impl App {
|
||||
id,
|
||||
));
|
||||
}
|
||||
// Refining a leaf moves its explicit responsibilities to the new leaf.
|
||||
// Later children inherit only the existing unambiguous performer summary.
|
||||
if self.create_kind.is_work()
|
||||
&& let Some(owner) = parent
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.layer() == self.layer && e.kind() == self.create_kind)
|
||||
{
|
||||
let performer = oa_core::validation::allocation_summary(self.project(), owner);
|
||||
let leaf = !self
|
||||
.project()
|
||||
.children(owner)
|
||||
.any(|e| e.kind() == self.create_kind);
|
||||
let responsibilities: Vec<_> = self
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.target() == owner
|
||||
&& matches!(
|
||||
r.kind(),
|
||||
RelationKind::Allocation | RelationKind::RoleAssignment
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if leaf && !responsibilities.is_empty() {
|
||||
for original in responsibilities {
|
||||
let mut responsibility = original.clone();
|
||||
match &mut responsibility.body {
|
||||
RelationBody::Allocation { target, .. }
|
||||
| RelationBody::RoleAssignment { target, .. } => *target = id,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
commands.push(Command::EditRelation(original.id, responsibility));
|
||||
}
|
||||
} else if let Some(performer) = performer {
|
||||
commands.push(Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Performs".into(),
|
||||
performer,
|
||||
id,
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.create_kind == Kind::Occurrence
|
||||
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
|
||||
&& let Some(steps) = owner.steps()
|
||||
@@ -3757,13 +3822,19 @@ impl App {
|
||||
.get(&diagram)
|
||||
.is_none_or(|d| d.projection.includes(self.create_kind))
|
||||
{
|
||||
let mut p = match execute_batch(self.project(), &commands) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return;
|
||||
let mut p = self.project().clone();
|
||||
for command in &commands {
|
||||
if let Command::CreateDiagram(d) = command {
|
||||
p.diagrams.insert(d.id, d.clone());
|
||||
}
|
||||
};
|
||||
}
|
||||
p.elements.insert(
|
||||
id,
|
||||
match &commands[0] {
|
||||
Command::CreateElement(e) => e.clone(),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let owner = if allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
@@ -4115,17 +4186,14 @@ impl App {
|
||||
}
|
||||
|
||||
fn jump_to_source(&mut self, location: SourceLocation) {
|
||||
self.source_workspace
|
||||
.switch(&location.path, self.source_editor.cursor());
|
||||
if self.source_workspace.active != location.path {
|
||||
let Some(target) = self.source_workspace.files.get(&location.path) else {
|
||||
self.status = format!(
|
||||
"{} has not been loaded. Use Load includes or create the missing file.",
|
||||
location.path
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.show_source_file(false);
|
||||
let source = self.source_workspace.text();
|
||||
};
|
||||
let source = &target.text;
|
||||
let mut offset = location.span.start.min(source.len());
|
||||
while !source.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
@@ -4135,6 +4203,9 @@ impl App {
|
||||
let column = prefix
|
||||
.rfind('\n')
|
||||
.map_or(offset, |start| offset - start - 1);
|
||||
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
|
||||
self.source_workspace.switch(&location.path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(
|
||||
text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
@@ -4161,6 +4232,7 @@ impl App {
|
||||
.is_ok_and(|includes| includes.iter().any(|include| include.target == path))
|
||||
});
|
||||
let mut changed = bundle.clone();
|
||||
let mut changed_entry = None;
|
||||
if !already_included {
|
||||
if self.source_dirty() {
|
||||
return Err(
|
||||
@@ -4180,16 +4252,30 @@ impl App {
|
||||
serde_json::to_string(&path).map_err(|e| e.to_string())?
|
||||
),
|
||||
);
|
||||
self.source_workspace
|
||||
.switch(&bundle.entry, self.source_editor.cursor());
|
||||
self.source_workspace
|
||||
.edit(entry.clone(), self.source_editor.cursor());
|
||||
changed_entry = Some(entry.clone());
|
||||
}
|
||||
// All fallible preparation is complete. The intermediate entry buffer
|
||||
// is not displayed, so it must not receive the outgoing editor's cursor.
|
||||
let original_path = self.source_workspace.active.clone();
|
||||
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
|
||||
if let Some(entry) = changed_entry {
|
||||
self.source_workspace.switch(&bundle.entry, departure);
|
||||
let cursor = if self.source_workspace.active == original_path {
|
||||
departure
|
||||
} else {
|
||||
self.source_workspace.files[&self.source_workspace.active].cursor
|
||||
};
|
||||
self.source_workspace.edit(entry, cursor);
|
||||
}
|
||||
changed.files.insert(path.clone(), String::new());
|
||||
self.source_workspace
|
||||
.discovered(&changed, changed.files.keys().cloned().collect());
|
||||
self.source_workspace
|
||||
.switch(&path, self.source_editor.cursor());
|
||||
let cursor = if self.source_workspace.active == original_path {
|
||||
departure
|
||||
} else {
|
||||
self.source_workspace.files[&self.source_workspace.active].cursor
|
||||
};
|
||||
self.source_workspace.switch(&path, cursor);
|
||||
self.source_workspace.new_path.clear();
|
||||
self.show_source_file(true);
|
||||
self.source_version += 1;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
//! 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 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);
|
||||
}
|
||||
let name = project.named(e.id).to_owned();
|
||||
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
|
||||
},
|
||||
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>,
|
||||
inspection_targets: Vec<usize>,
|
||||
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>,
|
||||
/// Last captured target names for inspection only; never resolver input.
|
||||
pub inspection_targets: Vec<usize>,
|
||||
pub text: String,
|
||||
pub refused: bool,
|
||||
}
|
||||
impl Feedback {
|
||||
pub fn inspection_identity(&self) -> String {
|
||||
if self.refused && self.targets.is_empty() && !self.inspection_targets.is_empty() {
|
||||
let names = self
|
||||
.inspection_targets
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
format!(
|
||||
"{}\nLast target{}: {}\nReturn to the canvas to choose a destination; releasing here cancels the drag.",
|
||||
self.destination_identity(),
|
||||
if self.inspection_targets.len() > 1 {
|
||||
"s (ambiguous)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
names
|
||||
)
|
||||
} else {
|
||||
self.destination_identity()
|
||||
}
|
||||
}
|
||||
/// Captured accepted names, independent of clipped status/refusal prose.
|
||||
pub fn destination_identity(&self) -> String {
|
||||
match self.targets.as_slice() {
|
||||
[] => if self.press.arrange {
|
||||
"Arrange only"
|
||||
} else if self.refused {
|
||||
"No eligible destination"
|
||||
} else {
|
||||
"Outside performers"
|
||||
}
|
||||
.into(),
|
||||
[index] => self.press.frozen.targets[*index].name.clone(),
|
||||
indices => format!(
|
||||
"Ambiguous: {}",
|
||||
indices
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[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
|
||||
&& self.diagram_usages.is_none()
|
||||
}
|
||||
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(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
) {
|
||||
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_press_owns_current_view(&self, press: &Press) -> bool {
|
||||
self.allocation_drag.active.is_none()
|
||||
&& self.allocation_stamp_valid(press.stamp)
|
||||
&& press.arrange == self.allocation_drag.arrange_next
|
||||
}
|
||||
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => {
|
||||
self.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.press.token == *token)
|
||||
}
|
||||
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
|
||||
// Invalid Press packets deliberately report why the view or draft
|
||||
// changed. Leave their existing validation and feedback intact.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.allocation_event_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
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_press_owns_current_view(&press) {
|
||||
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,
|
||||
inspection_targets: Vec::new(),
|
||||
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);
|
||||
if moved {
|
||||
match &hit {
|
||||
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
|
||||
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
let mut preview = None;
|
||||
let result: Result<String, String> = match hit {
|
||||
Hit::OutsideViewport => Err(if release {
|
||||
"Work drag canceled outside the canvas."
|
||||
} else {
|
||||
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
|
||||
}.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 }
|
||||
}),
|
||||
};
|
||||
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 {
|
||||
let inspection_targets = active.inspection_targets.clone();
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
press,
|
||||
targets,
|
||||
inspection_targets,
|
||||
text,
|
||||
refused,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allocation_drag_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,728 @@
|
||||
//! 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,
|
||||
std::slice::from_ref(&shortcut),
|
||||
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,
|
||||
std::slice::from_ref(&shortcut),
|
||||
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.region.y * f.app.zoom + f.app.pan.y < 0.);
|
||||
assert_eq!(feedback.destination_identity(), "A");
|
||||
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 destination identity"
|
||||
);
|
||||
}
|
||||
|
||||
fn destination_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted.geometry(app).bounds("allocation-destination")
|
||||
}
|
||||
fn assert_on_screen(bounds: Rectangle, app: &App) {
|
||||
assert!(
|
||||
bounds.x >= 0. && bounds.y >= 0. && bounds.width >= 400. && bounds.height == 26.,
|
||||
"{bounds:?}"
|
||||
);
|
||||
assert!(
|
||||
bounds.x + bounds.width <= app.window_size.width
|
||||
&& bounds.y + bounds.height <= app.window_size.height,
|
||||
"{bounds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canvas_stable() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (minimum, name, collapsed, zoom, pan) in [
|
||||
(false, "Receiver", false, 1., Vector::ZERO),
|
||||
(false, "Receiver", false, 0.75, Vector::new(37., -15.)),
|
||||
(
|
||||
false,
|
||||
"Receiving subsystem",
|
||||
true,
|
||||
1.25,
|
||||
Vector::new(-30., 23.),
|
||||
),
|
||||
(
|
||||
false,
|
||||
"Receiving subsystem",
|
||||
true,
|
||||
1.,
|
||||
Vector::new(0., -100.),
|
||||
),
|
||||
(true, "Receiving subsystem", true, 1., Vector::ZERO),
|
||||
(
|
||||
true,
|
||||
"Receiving subsystem WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
|
||||
true,
|
||||
1.,
|
||||
Vector::new(0., -100.),
|
||||
),
|
||||
] {
|
||||
let mut f = fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = name.into();
|
||||
let diagram = project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let target = diagram
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.collapsed = collapsed;
|
||||
let target_id = target.id;
|
||||
// At minimum size, keep the source in the visible canvas; preserve
|
||||
// the same standard (40,35) name grab and original target geometry.
|
||||
let source_y = if minimum { 210. } else { 500. };
|
||||
diagram.nodes.get_mut(&f.node).unwrap().position.y = source_y;
|
||||
replace(&mut f, project);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = if minimum {
|
||||
iced::Size::new(1100., 700.)
|
||||
} else {
|
||||
iced::Size::new(2000., 1400.)
|
||||
};
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = pan;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let slot = destination_bounds(&mut mounted, &f.app);
|
||||
assert_on_screen(slot, &f.app);
|
||||
assert!(slot.y + slot.height <= canvas.y);
|
||||
let idle = mounted.pixels(&f.app);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = at(&mut mounted, &f, xy(140., source_y + 35.));
|
||||
// Use the middle of the snap cell. Fractional App-layout origins
|
||||
// can perturb an exact half-grid tie by one floating-point ULP.
|
||||
// The packet/planner cases retain the original y140 counterexample.
|
||||
let to = at(&mut mounted, &f, xy(160., 145.));
|
||||
assert!(
|
||||
canvas.contains(from) && canvas.contains(to),
|
||||
"{canvas:?} {from:?} {to:?}"
|
||||
);
|
||||
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(&f.app.status);
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
assert_eq!(feedback.destination_identity(), name);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert_eq!(
|
||||
preview.placement.diagram.nodes[&f.node].position,
|
||||
xy(120., 110.),
|
||||
"{layer:?}, minimum={minimum}, zoom={zoom}, pan={pan:?}, canvas={canvas:?}"
|
||||
);
|
||||
assert!(!preview.placement.diagram.nodes[&target_id].collapsed);
|
||||
let selected = preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == f.node)
|
||||
.unwrap();
|
||||
let selected_header = Rectangle::new(
|
||||
Point::new(
|
||||
canvas.x + selected.position.x * zoom + pan.x,
|
||||
canvas.y + selected.position.y * zoom + pan.y,
|
||||
),
|
||||
iced::Size::new(
|
||||
selected.size.width * zoom,
|
||||
selected.size.height.min(62.) * zoom,
|
||||
),
|
||||
);
|
||||
assert!(canvas.contains(Point::new(selected_header.x, selected_header.y)));
|
||||
assert!(
|
||||
selected_header.x + selected_header.width <= canvas.x + canvas.width
|
||||
&& selected_header.y + selected_header.height <= canvas.y + canvas.height
|
||||
);
|
||||
let mut expected = preview.semantic.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
let active = mounted.pixels(&f.app);
|
||||
if minimum {
|
||||
record_render_evidence(
|
||||
&format!(
|
||||
"{layer:?}-minimum-{}",
|
||||
if pan.y < 0. { "top" } else { "normal" }
|
||||
),
|
||||
&active,
|
||||
&f.app,
|
||||
slot,
|
||||
canvas,
|
||||
);
|
||||
}
|
||||
let width = f.app.window_size.width as usize;
|
||||
assert_ne!(
|
||||
canvas_pixels(&idle, width, slot),
|
||||
canvas_pixels(&active, width, slot),
|
||||
"actual destination text is painted in its dedicated visible slot"
|
||||
);
|
||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
||||
"Long refusal/status prose must not obscure work or destination. ".repeat(30);
|
||||
let verbose = mounted.pixels(&f.app);
|
||||
assert_ne!(active, verbose);
|
||||
assert_eq!(
|
||||
canvas_pixels(&active, width, canvas),
|
||||
canvas_pixels(&verbose, width, canvas)
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&active, width, slot),
|
||||
canvas_pixels(&verbose, width, slot)
|
||||
);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
if pan.y == -100. {
|
||||
// A genuine clipping refusal in the same held gesture, then
|
||||
// recovery at the original exact point: no camera rebasing.
|
||||
let clipped = at(&mut mounted, &f, xy(160., 110.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved {
|
||||
position: clipped,
|
||||
})],
|
||||
mouse::Cursor::Available(clipped),
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: to })],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
}
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.project(), &expected, "{}", f.app.status);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional reproducible visual evidence; normal test runs create no artifacts.
|
||||
fn record_render_evidence(
|
||||
name: &str,
|
||||
pixels: &[u8],
|
||||
app: &App,
|
||||
slot: Rectangle,
|
||||
canvas: Rectangle,
|
||||
) {
|
||||
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
|
||||
return;
|
||||
};
|
||||
let path = std::path::PathBuf::from(path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.rgba")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, pixels).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"width": app.window_size.width as u32, "height": app.window_size.height as u32,
|
||||
"destination": [slot.x, slot.y, slot.width, slot.height],
|
||||
"canvas": [canvas.x, canvas.y, canvas.width, canvas.height],
|
||||
"identity": app.allocation_drag.feedback.as_ref().map(|f| f.destination_identity()),
|
||||
});
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.json")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_outside_drop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let name = "Receiving subsystem with a long exact identity beyond the compact label";
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = name.into();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.node)
|
||||
.unwrap()
|
||||
.position
|
||||
.y = 210.;
|
||||
replace(&mut f, project);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = iced::Size::new(1100., 700.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
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;
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let slot = destination_bounds(&mut mounted, &f.app);
|
||||
let from = at(&mut mounted, &f, xy(140., 245.));
|
||||
let to = at(&mut mounted, &f, xy(160., 145.));
|
||||
let before = f.app.document.bundle().clone();
|
||||
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;
|
||||
assert_eq!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.destination_identity(),
|
||||
name
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
let hover = Point::new(slot.x + 45., slot.y + 8.);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: hover })],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.refused);
|
||||
assert!(feedback.targets.is_empty());
|
||||
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
||||
assert!(
|
||||
feedback
|
||||
.inspection_identity()
|
||||
.contains(&format!("Last target: {name}"))
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(f.app.allocation_drag.active.is_some());
|
||||
assert!(f.app.status.starts_with("Pointer outside canvas"));
|
||||
let inspected = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
|
||||
// Iced's Tooltip overlay draws its child but exposes no Operation
|
||||
// traversal. Compare the real rendered overlay instead of inventing
|
||||
// container geometry that the installed widget cannot report.
|
||||
let inspection = std::mem::take(
|
||||
&mut f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.inspection_targets,
|
||||
);
|
||||
let without_name = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
|
||||
let width = f.app.window_size.width as usize;
|
||||
assert_ne!(
|
||||
inspected, without_name,
|
||||
"full captured name changes the actual hovered tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&inspected, width, canvas),
|
||||
canvas_pixels(&without_name, width, canvas)
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&inspected, width, slot),
|
||||
canvas_pixels(&without_name, width, slot)
|
||||
);
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.inspection_targets = inspection;
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
record_render_evidence(
|
||||
&format!("{layer:?}-name-inspection"),
|
||||
&inspected,
|
||||
&f.app,
|
||||
slot,
|
||||
canvas,
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
|
||||
// Inspection grants no mutation authority. Releasing over it cancels.
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.feedback.is_none());
|
||||
assert_eq!(f.app.status, "Work drag canceled outside the canvas.");
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
// A fresh gesture cannot inherit the previous inspection name.
|
||||
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: hover }),
|
||||
],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.inspection_targets.is_empty());
|
||||
assert!(!feedback.inspection_identity().contains(name));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a)
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
//! Captured real canvas packets against a later navigation or live gesture.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target};
|
||||
|
||||
fn navigate_work(f: &mut Fixture) -> usage::Usage {
|
||||
let message = f.app.diagram_usage_message(f.work);
|
||||
let _ = f.app.update(message);
|
||||
let panel = f.app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let context = panel.context;
|
||||
let process = f.app.behavior.topology.usage_snapshot().into_query_state();
|
||||
let completion = usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(usage::collect(
|
||||
f.app.project(),
|
||||
f.work,
|
||||
&process,
|
||||
&crate::behavior::scenario::State::default(),
|
||||
)),
|
||||
};
|
||||
let _ = f.app.update(Message::DiagramUsagesReady(completion));
|
||||
let found = f
|
||||
.app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.find(|usage| usage.target == Target::Placement(f.node))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = f.app.update(Message::DiagramUsage(usage::Event::Choose(
|
||||
token,
|
||||
found.clone(),
|
||||
)));
|
||||
assert!(f.app.pending_diagram_reveal.is_some());
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
found
|
||||
}
|
||||
|
||||
fn assert_stale_noop(app: &mut App, message: Message) {
|
||||
let before = app.document.snapshot();
|
||||
let selection = (app.selected, app.selected_node, app.active_diagram);
|
||||
let camera = (app.zoom, app.pan, app.canvas_epoch, app.camera_epoch);
|
||||
let pending = app.pending_diagram_reveal.clone();
|
||||
let focus = app
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.map(|focus| (focus.context, focus.usage.clone(), focus.selected));
|
||||
let allocation = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let active = app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| (active.press.token, active.cache.len()));
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
(app.selected, app.selected_node, app.active_diagram),
|
||||
selection
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.pending_diagram_reveal, pending);
|
||||
assert_eq!(
|
||||
app.diagram_navigation_focus.as_ref().map(|focus| (
|
||||
focus.context,
|
||||
focus.usage.clone(),
|
||||
focus.selected
|
||||
)),
|
||||
focus
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
allocation
|
||||
);
|
||||
assert_eq!(
|
||||
app.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| (active.press.token, active.cache.len())),
|
||||
active
|
||||
);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
fn stale_packets(press: &Press) -> Vec<Message> {
|
||||
vec![
|
||||
Message::WorkDrag(Event::Cancel(press.token)),
|
||||
Message::WorkDrag(Event::Preview(press.stamp, press.token, Some(press.start))),
|
||||
Message::WorkDrag(Event::Release(press.stamp, press.token, Some(press.start))),
|
||||
Message::WorkDrag(Event::Refused(press.stamp.epoch, "old refusal".into())),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
let old = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
old.token
|
||||
);
|
||||
let found = navigate_work(&mut f);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert_ne!(f.app.allocation_drag.epoch, old.stamp.epoch);
|
||||
for message in stale_packets(&old) {
|
||||
assert_stale_noop(&mut f.app, message);
|
||||
}
|
||||
// A rejected press still explains the invalid view, but cannot consume the
|
||||
// new destination's pending first measurement.
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let epoch = f.app.allocation_drag.epoch;
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert_eq!(f.app.allocation_drag.epoch, epoch);
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
assert_eq!(f.app.status, "The view changed. Start this drag again.");
|
||||
let (context, _, epoch) = f.app.pending_diagram_reveal.clone().unwrap();
|
||||
let _ = f.app.update(Message::DiagramRevealViewport(
|
||||
context,
|
||||
epoch,
|
||||
bounds().size(),
|
||||
));
|
||||
assert!(f.app.pending_diagram_reveal.is_none());
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
|
||||
// Restore the fixture's camera through current App actions, then capture a
|
||||
// fresh packet through the actual DiagramCanvas rather than seeding active.
|
||||
let factor = 1. / f.app.zoom;
|
||||
let _ = f.app.update(Message::Zoom(factor));
|
||||
let delta = -f.app.pan;
|
||||
let _ = f.app.update(Message::Pan(delta));
|
||||
let current = press(&f);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Press(current.clone())));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
current.token
|
||||
);
|
||||
assert!(
|
||||
f.app.diagram_navigation_focus.is_none(),
|
||||
"live press retires navigation"
|
||||
);
|
||||
for message in stale_packets(&old) {
|
||||
assert_stale_noop(&mut f.app, message);
|
||||
}
|
||||
let epoch = f.app.allocation_drag.epoch;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Cancel(current.token)));
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.epoch > epoch);
|
||||
assert_eq!(f.app.status, "Work drag canceled");
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_chooser_envelopes_preserve_a_current_canvas_gesture() {
|
||||
let mut previous = fixture(Layer::Oa);
|
||||
let old_open = previous.app.diagram_usage_message(previous.work);
|
||||
let _ = previous.app.update(old_open.clone());
|
||||
let old_token = previous.app.diagram_usages.as_ref().unwrap().token;
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let current = press(&f);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Press(current.clone())));
|
||||
assert_stale_noop(&mut f.app, old_open);
|
||||
assert_stale_noop(
|
||||
&mut f.app,
|
||||
Message::DiagramUsage(usage::Event::Close(old_token)),
|
||||
);
|
||||
assert_stale_noop(
|
||||
&mut f.app,
|
||||
Message::DiagramUsage(usage::Event::Search(old_token, "old".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
current.token
|
||||
);
|
||||
let open = f.app.diagram_usage_message(f.work);
|
||||
let _ = f.app.update(open);
|
||||
assert!(
|
||||
f.app.diagram_usages.is_some(),
|
||||
"current chooser remains live"
|
||||
);
|
||||
assert!(
|
||||
f.app.allocation_drag.active.is_none(),
|
||||
"current chooser retires gesture"
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ 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 {
|
||||
@@ -89,17 +90,8 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.is_none()
|
||||
);
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(iced::Point::new(150., 120.)),
|
||||
);
|
||||
// Program receives keys already admitted by diagram::focus::Owner. Real
|
||||
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
|
||||
let action = canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.unwrap();
|
||||
|
||||
@@ -233,6 +233,7 @@ 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
|
||||
@@ -308,6 +309,7 @@ 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.));
|
||||
@@ -321,16 +323,11 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
);
|
||||
// Program receives keys already admitted by diagram::focus::Owner. Real
|
||||
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
|
||||
let message = canvas
|
||||
.update(&mut state, &key, bounds, cursor)
|
||||
.expect("focused F2 opens the selected semantic name")
|
||||
.expect("admitted F2 opens the selected semantic name")
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
|
||||
@@ -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,
|
||||
Message::ConfirmDelete(0),
|
||||
Message::Undo,
|
||||
Message::Redo,
|
||||
Message::ShortcutUndo(false),
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
//! Delete through the actual App widget tree, ordinary focus, and source history.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
canvas_workspace::tests::{Mounted, focused, key},
|
||||
scenario, topology,
|
||||
};
|
||||
use iced::{Event as Input, Point, Size, Vector, keyboard, mouse};
|
||||
|
||||
fn app(p: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.window_size = Size::new(1900., 1200.);
|
||||
app
|
||||
}
|
||||
fn point(mounted: &mut Mounted, app: &App, id: Id) -> Point {
|
||||
if let Some(context) = app.topology_context() {
|
||||
let bounds = mounted.geometry(app).bounds("process-viewport");
|
||||
let local = app
|
||||
.behavior
|
||||
.topology
|
||||
.removal_test_point(app, context.owner, id, None);
|
||||
bounds.position() + Vector::new(local.x, local.y)
|
||||
} else {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let bounds = mounted.geometry(app).bounds("scenario-viewport");
|
||||
let local = scenario::removal_test_point(app, context.scenario, id);
|
||||
bounds.position() + Vector::new(local.x, local.y)
|
||||
}
|
||||
}
|
||||
async fn select(mounted: &mut Mounted, app: &mut App, id: Id) {
|
||||
let target = point(mounted, app, id);
|
||||
mounted.click(app, target).await;
|
||||
assert_eq!(app.selected, id, "real pointer selects intended occurrence");
|
||||
}
|
||||
fn remove_key(shift: bool) -> Input {
|
||||
let mut event = key(keyboard::key::Named::Delete);
|
||||
if shift && let Input::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut event {
|
||||
*modifiers = keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
event
|
||||
}
|
||||
fn removal(message: &Message) -> bool {
|
||||
matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::Remove(..))
|
||||
| Message::ScenarioCanvas(scenario::CanvasEvent::Remove(..))
|
||||
)
|
||||
}
|
||||
async fn delete(mounted: &mut Mounted, app: &mut App, shift: bool) -> u64 {
|
||||
let messages = mounted
|
||||
.batch(app, &[remove_key(shift)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(removal),
|
||||
"focused viewport emits removal: {messages:?}"
|
||||
);
|
||||
let target = app.delete_target.as_ref().expect("captured confirmation");
|
||||
assert_eq!(target.snapshot.request.object, app.selected);
|
||||
assert_eq!(target.placement, None);
|
||||
assert_eq!(target.snapshot.request.placement, None);
|
||||
target.snapshot.token
|
||||
}
|
||||
async fn send(mounted: &mut Mounted, app: &mut App, message: Message) {
|
||||
let task = app.update(message);
|
||||
mounted.perform(app, task).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_process_delete_occurrence_or_link_preserves_reusable_work_and_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for link in [false, true] {
|
||||
let f = topology::direct_tests::fixture(layer);
|
||||
let target = if link { f.link } else { f.nodes[0] };
|
||||
let mut app = app(f.p.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
// No extra redraw between release and first Delete.
|
||||
let token = delete(&mut mounted, &mut app, link).await;
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().behavior_owner,
|
||||
Some(f.owner)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(f.work.iter().all(|id| app.project().contains(*id)));
|
||||
assert!(app.project().contains(f.exchange));
|
||||
assert!(app.project().contains(f.nodes[1]));
|
||||
assert_eq!(app.project().contains(f.nodes[0]), link);
|
||||
assert!(!app.project().contains(f.link));
|
||||
consistent(&app);
|
||||
let removed = app.document.bundle().clone();
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Redo).await;
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
consistent(&app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_delete_message_or_participant_preserves_reusable_targets() {
|
||||
for participant in [false, true] {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let target = if participant { f.sender } else { f.call };
|
||||
let mut app = app(f.project.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let token = delete(&mut mounted, &mut app, participant).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(app.project().contains(f.exchange));
|
||||
assert!(app.project().contains(f.receiver));
|
||||
assert!(app.project().contains(f.reply));
|
||||
for element in f
|
||||
.project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| element.parent != Some(f.owner))
|
||||
{
|
||||
assert!(
|
||||
app.project().contains(element.id),
|
||||
"referenced definitions survive"
|
||||
);
|
||||
}
|
||||
if participant {
|
||||
assert!(app.project().contains(f.call));
|
||||
for element in app.project().elements.values() {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
},
|
||||
..
|
||||
}) = element.shared()
|
||||
{
|
||||
assert_ne!(*sender, Some(target));
|
||||
assert_ne!(*receiver, Some(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
consistent(&app);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects_owner_replay() {
|
||||
let (p, owners, uses) = scenario::direct_tests::navigation_fixture();
|
||||
let mut app = app(p, owners[0]);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let stale = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert!(app.delete_pending, "old token does not cancel new dialog");
|
||||
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
|
||||
app.behavior.active_owner = Some(owners[1]);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
app.behavior.active_owner = Some(owners[0]);
|
||||
mounted.settle(&mut app).await;
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(uses[0]));
|
||||
assert!(app.project().contains(uses[1]));
|
||||
assert!(owners.iter().all(|id| app.project().contains(*id)));
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_tab_and_f6_retire_same_batch_delete_and_window_focus() {
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
for boundary in [
|
||||
keyboard::key::Named::Tab,
|
||||
keyboard::key::Named::F6,
|
||||
keyboard::key::Named::Escape,
|
||||
] {
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(boundary), remove_key(false)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"{boundary:?} retires keyboard admission within the batch"
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
if boundary == keyboard::key::Named::Tab {
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.count(),
|
||||
1,
|
||||
"Tab completes traversal to one real control"
|
||||
);
|
||||
let mut backwards = key(keyboard::key::Named::Tab);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) =
|
||||
&mut backwards
|
||||
{
|
||||
*modifiers = keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
mounted
|
||||
.batch(&mut app, &[backwards], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::F6), remove_key(false)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"F6 entry completes after the batch, before a later Delete"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
focused(&mut mounted, &app, "viewport"),
|
||||
"{boundary:?} supports keyboard-only return to the viewport"
|
||||
);
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
Input::Window(iced::window::Event::Unfocused),
|
||||
remove_key(false),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
// A new actual click reacquires ownership after each boundary.
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_clean_inspector_click_and_tab_own_delete_then_f6_returns_to_canvas() {
|
||||
for process in [false, true] {
|
||||
for tab_entry in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let original_name = app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
|
||||
// The first text input in the right-hand Inspector is the name.
|
||||
// Verify its actual binding below, rather than seeding focus state.
|
||||
let field = 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))
|
||||
.expect("visible Inspector name input");
|
||||
if tab_entry {
|
||||
// Canvas Tab stays within canvas controls. Escape then ordinary
|
||||
// Tab follows the existing Inspector entry policy.
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::Tab)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
mounted.click(&mut app, field.center()).await;
|
||||
}
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, value)| *bounds == field && *value),
|
||||
"actual {} focuses the Inspector name",
|
||||
if tab_entry { "Tab" } else { "click" }
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert!(!app.draft_dirty());
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::End), remove_key(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::Field(path, value) if path == "/name" && *value == original_name
|
||||
)));
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
assert!(!app.draft_dirty(), "Delete at end keeps the field clean");
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::F6)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.count(),
|
||||
1,
|
||||
"F6 releases actual Inspector focus"
|
||||
);
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_clean_text_focus_and_modifiers_cannot_delete_selected_occurrence() {
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
struct FocusText(iced::Rectangle);
|
||||
impl Operation for FocusText {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: iced::Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
for modifiers in [
|
||||
keyboard::Modifiers::CTRL,
|
||||
keyboard::Modifiers::ALT,
|
||||
keyboard::Modifiers::LOGO,
|
||||
] {
|
||||
let mut event = remove_key(false);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
modifiers: value, ..
|
||||
}) = &mut event
|
||||
{
|
||||
*value = modifiers;
|
||||
}
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[event], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
}
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.find(|bounds| bounds.width > 40.)
|
||||
.expect("real text field");
|
||||
mounted.operate(&app, &mut FocusText(field));
|
||||
assert!(!app.draft_dirty());
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[remove_key(true)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_fragment_and_branch_delete_only_owned_occurrences() {
|
||||
for whole_fragment in [false, true] {
|
||||
let (f, branch, anchor, state, reused, remote) =
|
||||
scenario::direct_tests::placement_fixture();
|
||||
let fragment = f.project.elements[&branch].parent.unwrap();
|
||||
let target = if whole_fragment { fragment } else { branch };
|
||||
let mut app = app(f.project.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(!app.project().contains(anchor));
|
||||
assert!(!app.project().contains(branch));
|
||||
assert_eq!(app.project().contains(fragment), !whole_fragment);
|
||||
for id in [
|
||||
f.call, f.reply, f.sender, f.receiver, f.exchange, state, reused, remote,
|
||||
] {
|
||||
assert!(app.project().contains(id));
|
||||
}
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shared_definition() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut p = f.p;
|
||||
let caller = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Caller", None)
|
||||
.unwrap();
|
||||
let references = ["First use", "Second use"].map(|name| {
|
||||
let mut element = p
|
||||
.make_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
|
||||
.unwrap();
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(f.owner),
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let id = element.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
});
|
||||
let mut app = app(p, caller);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![references[0]],
|
||||
occurrence: f.nodes[0],
|
||||
};
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let local =
|
||||
app.behavior
|
||||
.topology
|
||||
.removal_test_point(&app, caller, references[0], Some(&endpoint));
|
||||
let messages = mounted
|
||||
.click(&mut app, bounds.position() + Vector::new(local.x, local.y))
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
|
||||
)));
|
||||
assert_eq!(app.selected, references[0]);
|
||||
assert!(
|
||||
app.behavior
|
||||
.topology
|
||||
.has_qualified_selection(app.project(), caller)
|
||||
);
|
||||
mounted
|
||||
.batch(&mut app, &[remove_key(false)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(app.status.contains("Open the referenced definition"));
|
||||
select(&mut mounted, &mut app, references[0]).await;
|
||||
assert!(
|
||||
!app.behavior
|
||||
.topology
|
||||
.has_qualified_selection(app.project(), caller)
|
||||
);
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(references[0]));
|
||||
assert!(app.project().contains(references[1]));
|
||||
assert!(app.project().contains(f.owner));
|
||||
assert!(f.nodes.iter().all(|id| app.project().contains(*id)));
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_pan_and_stale_removal_intent_do_not_delete() {
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let cursor = point(&mut mounted, &app, target);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: cursor }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
remove_key(false),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(cursor),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"held pan never becomes an occurrence deletion"
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted.input(&app, &[remove_key(false)], mouse::Cursor::Unavailable);
|
||||
let stale = messages
|
||||
.into_iter()
|
||||
.find(removal)
|
||||
.expect("captured live removal");
|
||||
app.canvas_epoch = app.canvas_epoch.wrapping_add(1);
|
||||
send(&mut mounted, &mut app, stale).await;
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Keyboard ownership is exercised on the mounted App, not raw Canvas Program.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Point, Rectangle, Size, Vector, keyboard::key::Named, mouse};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn f2(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(app, &[key(Named::F2)], mouse::Cursor::Unavailable)
|
||||
.await
|
||||
}
|
||||
|
||||
fn rename_emitted(messages: &[Message]) -> bool {
|
||||
messages.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
Message::Architecture(architecture_authoring::Event::Rename(..))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for programmatic in [true, false] {
|
||||
let mut project = Project::empty();
|
||||
let object = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Original team",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(layer);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let node = project.place(diagram, object, Position { x: 80., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(object));
|
||||
let before = app.document.snapshot();
|
||||
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;
|
||||
|
||||
// Selection alone does not grant the canvas keyboard ownership.
|
||||
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let projected = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|item| item.id == node)
|
||||
.unwrap();
|
||||
let point = Point::new(
|
||||
bounds.x + projected.position.x + 40.,
|
||||
bounds.y + projected.position.y + 35.,
|
||||
);
|
||||
mounted.click(&mut app, point).await;
|
||||
assert_eq!((app.selected, app.selected_node), (object, Some(node)));
|
||||
assert!(rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
let rename = app.architecture.as_ref().expect("canvas F2 opens rename");
|
||||
assert!(rename.inline());
|
||||
assert!(matches!(
|
||||
&rename.saved.intent,
|
||||
architecture_authoring::Intent::Rename { element, name }
|
||||
if *element == object && name == "Original team"
|
||||
));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"opening rename is not a model edit"
|
||||
);
|
||||
|
||||
let task = app.update(Message::Escape);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert!(app.architecture.is_none());
|
||||
mounted.click(&mut app, point).await;
|
||||
let field = 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))
|
||||
.expect("Inspector name field is mounted");
|
||||
if programmatic {
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(Named::Tab), key(Named::F2)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!rename_emitted(&messages),
|
||||
"Tab must retire F2 admission before its queued focus task"
|
||||
);
|
||||
}
|
||||
let geometry = mounted.geometry(&app);
|
||||
assert!(
|
||||
geometry
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| { *bounds == field && *focused })
|
||||
);
|
||||
assert!(geometry.focused.iter().any(|(id, _, focused)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
&& !*focused
|
||||
}));
|
||||
assert!(!app.draft_dirty(), "the Inspector is still clean");
|
||||
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Original team"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 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 behavior_owner: Option<Id>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
!app.busy && focus_enabled(app)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_enabled(app: &App) -> bool {
|
||||
crate::behavior::canvas_workspace::keyboard_owner_enabled(app)
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
&& !app.behavior.has_pending()
|
||||
&& app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_none_or(|state| !state.inline())
|
||||
}
|
||||
|
||||
pub(crate) fn behavior_owner(app: &App) -> Option<Id> {
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| app.scenario_context().map(|context| context.scenario))
|
||||
}
|
||||
|
||||
fn placement(app: &App) -> Option<Id> {
|
||||
if behavior_owner(app).is_some() {
|
||||
None
|
||||
} else if app.page == Page::Behavior {
|
||||
app.behavior.diagram.placement
|
||||
} else {
|
||||
app.selected_node
|
||||
}
|
||||
}
|
||||
|
||||
/// The derived canvases remove semantic occurrences, not reusable definitions.
|
||||
/// Kept beside target capture so confirmation rendering uses the same meaning.
|
||||
pub(crate) fn explanation(app: &App, object: Id) -> &'static str {
|
||||
if app
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.any(|diagram| diagram.nodes.contains_key(&object))
|
||||
{
|
||||
return "Remove only this diagram placement. The model element and its placements in other diagrams remain. Undo restores this placement.";
|
||||
}
|
||||
if app
|
||||
.project()
|
||||
.relations
|
||||
.get(&object)
|
||||
.is_some_and(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::StateAction | RelationKind::StateTransition
|
||||
)
|
||||
})
|
||||
{
|
||||
return "Remove this state relation. Its states and referenced activities or functions remain. Undo restores the relation.";
|
||||
}
|
||||
if app
|
||||
.project()
|
||||
.relations
|
||||
.get(&object)
|
||||
.is_some_and(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::SequenceLink | RelationKind::ContextualLink
|
||||
)
|
||||
})
|
||||
{
|
||||
return "Remove this process link. Its endpoint occurrences and referenced reusable model elements are preserved. Undo restores the deletion.";
|
||||
}
|
||||
match app
|
||||
.project()
|
||||
.elements
|
||||
.get(&object)
|
||||
.and_then(|element| element.shared())
|
||||
{
|
||||
Some(SharedBody::Lifeline { .. }) => {
|
||||
"Remove this participant from the scenario. Its represented model element is preserved. Messages and other occurrences remain with affected participant references cleared. Undo restores the deletion."
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { .. } | OccurrenceBody::Branch { .. },
|
||||
..
|
||||
}) => {
|
||||
"Remove this occurrence and its owned branches and steps. Referenced reusable model elements are preserved. Undo restores the deletion."
|
||||
}
|
||||
Some(SharedBody::Occurrence { .. }) => {
|
||||
"Remove this occurrence and its dependent links. Referenced reusable model elements are preserved. Undo restores the deletion."
|
||||
}
|
||||
_ => {
|
||||
"Owned children, incident relations, occurrences, placements and trace links will also be removed. The traced counterpart itself is preserved. Undo restores the deletion."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
let selected_object = self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&intent.context.diagram)
|
||||
.and_then(|diagram| diagram.nodes.get(&self.selected))
|
||||
.map_or(self.selected, |node| node.element);
|
||||
if current != Some(intent.context) || selected_object != 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)
|
||||
|| (self.page == Page::Behavior
|
||||
&& crate::behavior::state_canvas::visible_row_relation(
|
||||
self.project(),
|
||||
intent.context.diagram,
|
||||
&view,
|
||||
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_target(intent.object, Some(intent.context));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_model_delete(&mut self, canvas: Option<Context>) {
|
||||
self.begin_model_delete_target(self.selected, canvas);
|
||||
}
|
||||
|
||||
fn begin_model_delete_target(&mut self, object: Id, canvas: Option<Context>) {
|
||||
if object == self.project().id || self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
let mut request = model_menu::Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = canvas;
|
||||
request.placement = placement(self);
|
||||
// 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: placement(self),
|
||||
behavior_owner: behavior_owner(self),
|
||||
}
|
||||
});
|
||||
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 == placement(self)
|
||||
&& target.behavior_owner == behavior_owner(self)
|
||||
&& !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));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
//! Navigate first, then remove through the mounted root canvas and history.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target};
|
||||
|
||||
async fn navigate_then_remove(project: Project, subject: Id, owner: Id, row: bool) {
|
||||
let mut app = App::with_project(project);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let before = app.document.snapshot();
|
||||
let message = app.diagram_usage_message(subject);
|
||||
let _ = app.update(message);
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let context = panel.context;
|
||||
// Complete the same query independently without polling its worker Task.
|
||||
let process = app.behavior.topology.usage_snapshot().into_query_state();
|
||||
let completion = usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(usage::collect(
|
||||
app.project(),
|
||||
subject,
|
||||
&process,
|
||||
&behavior::scenario::State::default(),
|
||||
)),
|
||||
};
|
||||
let _ = app.update(Message::DiagramUsagesReady(completion));
|
||||
let found = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.find(|usage| {
|
||||
if row {
|
||||
matches!(usage.target, Target::StateRow { relation, .. } if relation == subject)
|
||||
} else {
|
||||
matches!(usage.target, Target::Port { port, .. } if port == subject)
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = app.update(Message::DiagramUsage(usage::Event::Choose(
|
||||
token,
|
||||
found.clone(),
|
||||
)));
|
||||
assert_eq!(app.selected, subject);
|
||||
// refresh_draft normalizes the owner's presentation placement; navigation
|
||||
// must not leave it armed as a removal target.
|
||||
assert!(app.selected_node.is_none());
|
||||
assert!(app.behavior.diagram.placement.is_none());
|
||||
assert_eq!(
|
||||
app.diagram_focus(found.surface).unwrap().target,
|
||||
found.target
|
||||
);
|
||||
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
for model in [false, true] {
|
||||
mounted
|
||||
.perform(
|
||||
&mut app,
|
||||
iced::widget::operation::focus(iced::advanced::widget::Id::from(
|
||||
"diagram-keyboard-owner",
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let messages = delete(&mut mounted, &mut app, model).await;
|
||||
let request = messages
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
Message::DiagramRemove(intent)
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Remove(intent)) => {
|
||||
Some(intent)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("actual focused canvas emitted a removal intent");
|
||||
assert_eq!(request.object, subject);
|
||||
assert_eq!(request.placement, None);
|
||||
assert_eq!(request.model, model);
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().snapshot.request.object,
|
||||
subject
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
if !model {
|
||||
let task = app.update(Message::CancelDelete);
|
||||
mounted.perform(&mut app, task).await;
|
||||
}
|
||||
}
|
||||
let token = app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
let task = app.update(Message::ConfirmDelete(token));
|
||||
mounted.perform(&mut app, task).await;
|
||||
let expected = execute_batch(&before.decoded.project, &[Command::Delete(subject)]).unwrap();
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().contains(owner));
|
||||
consistent(&app);
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_navigation_then_canvas_delete_preserves_owner_and_restores_exact_history() {
|
||||
let mut project = Project::empty();
|
||||
let owner = project.add(Kind::Function, "Compute", 1200., 1500.);
|
||||
let port = Id::new_v4();
|
||||
let element = oa_core::ports::make_port(
|
||||
&project,
|
||||
port,
|
||||
owner,
|
||||
"Input",
|
||||
oa_core::PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
navigate_then_remove(project, port, owner, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_row_navigation_then_canvas_delete_preserves_work_and_restores_exact_history() {
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, Layer::Sa, "Running", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
|
||||
.unwrap();
|
||||
let relation = project.relate(RelationKind::StateAction, "Entry", state, work);
|
||||
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
|
||||
project = execute_batch(&project, &[plan.command]).unwrap();
|
||||
navigate_then_remove(project, relation, work, true).await;
|
||||
}
|
||||
@@ -85,6 +85,9 @@ impl App {
|
||||
| Message::DiagramUsage(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)
|
||||
| Message::WorkDrag(allocation_drag::Event::Unfocused)
|
||||
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
|
||||
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
@@ -109,13 +112,11 @@ impl App {
|
||||
behavior::topology::CanvasEvent::Camera(..)
|
||||
| behavior::topology::CanvasEvent::Zoom(..)
|
||||
| behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| behavior::topology::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::ScenarioCanvas(
|
||||
behavior::scenario::CanvasEvent::Camera(..)
|
||||
| behavior::scenario::CanvasEvent::Zoom(..)
|
||||
| behavior::scenario::CanvasEvent::Scrolled(..)
|
||||
| behavior::scenario::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::StateDiagram(
|
||||
behavior::state_edit::Event::Pan(..)
|
||||
|
||||
@@ -15,6 +15,7 @@ impl App {
|
||||
|
||||
fn usage_navigation_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& !self.source_rename.open()
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
@@ -143,14 +144,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if let Event::Open(context) = event {
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| !self.project().contains(context.subject)
|
||||
{
|
||||
return Task::none();
|
||||
pub(super) fn diagram_usage_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Open(context) => {
|
||||
context.document == self.canvas_document
|
||||
&& context.revision == self.document.revision()
|
||||
&& self.project().contains(context.subject)
|
||||
}
|
||||
Event::Close(token)
|
||||
| Event::Search(token, _)
|
||||
| Event::Page(token, _)
|
||||
| Event::Move(token, _)
|
||||
| Event::Activate(token)
|
||||
| Event::Destination(token, _)
|
||||
| Event::Choose(token, _) => self
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.token == *token),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.diagram_usage_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Event::Open(context) = event {
|
||||
if !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -170,22 +188,10 @@ impl App {
|
||||
iced::widget::operation::focus(filter_id),
|
||||
]);
|
||||
}
|
||||
let token = match &event {
|
||||
Event::Open(_) => unreachable!(),
|
||||
Event::Close(t)
|
||||
| Event::Search(t, _)
|
||||
| Event::Page(t, _)
|
||||
| Event::Move(t, _)
|
||||
| Event::Activate(t)
|
||||
| Event::Destination(t, _)
|
||||
| Event::Choose(t, _) => *t,
|
||||
};
|
||||
let Some(panel) = &self.diagram_usages else {
|
||||
return Task::none();
|
||||
};
|
||||
if panel.token != token {
|
||||
return Task::none();
|
||||
}
|
||||
let panel = self
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("admitted chooser token");
|
||||
if matches!(event, Event::Close(_)) {
|
||||
self.diagram_usages = None;
|
||||
if self.page == Page::Text {
|
||||
|
||||
@@ -1029,10 +1029,9 @@ fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_li
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
if call == calls[1]
|
||||
&& let Some((previous_call, previous)) = &previous_camera
|
||||
&& *previous_call != call
|
||||
{
|
||||
if *previous_call != call {
|
||||
assert!(&camera != previous);
|
||||
}
|
||||
assert!(&camera != previous);
|
||||
}
|
||||
if let Some(epoch) = previous_epoch {
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
@@ -1375,7 +1374,7 @@ fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable()
|
||||
};
|
||||
let viewport = iced::Size::new(800., 500.);
|
||||
let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport);
|
||||
assert!(zoom < 0.05 && zoom >= 0.0001);
|
||||
assert!((0.0001..0.05).contains(&zoom));
|
||||
assert!(bounds.width * zoom <= viewport.width - 64. + 0.01);
|
||||
assert!(bounds.height * zoom <= viewport.height - 64. + 0.01);
|
||||
assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01);
|
||||
|
||||
@@ -74,11 +74,7 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
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),
|
||||
@@ -97,6 +93,26 @@ impl App {
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Remove(_, id) => {
|
||||
if diagram_removal::enabled(self)
|
||||
&& self.selected == id
|
||||
&& self.behavior.topology.selecting()
|
||||
{
|
||||
if self
|
||||
.behavior
|
||||
.topology
|
||||
.has_qualified_selection(self.project(), context.owner)
|
||||
{
|
||||
self.status = "Open the referenced definition to delete its contents, or select the outer reference to remove that use.".into();
|
||||
} else if self.behavior.topology.removal_target(
|
||||
self.project(),
|
||||
context.owner,
|
||||
id,
|
||||
) {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
CanvasEvent::ExpandQualified(_, endpoint) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand_context(self.project(), context.owner, endpoint);
|
||||
@@ -143,10 +159,6 @@ impl App {
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Focus(_) => {
|
||||
self.behavior.topology.focused = !self.behavior.topology.focused;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
|
||||
@@ -329,7 +341,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
|
||||
fn process_zoom_retains_rejected_draft_and_guards_selection() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let initial = app.selected;
|
||||
@@ -339,9 +351,14 @@ mod tests {
|
||||
&mut app,
|
||||
Event::Name(" unfinished contextual work ".into()),
|
||||
);
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(app.behavior.topology.focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(0.75),
|
||||
)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert!(app.behavior.topology.camera_boundary() != camera);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
@@ -353,8 +370,8 @@ mod tests {
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(!app.behavior.topology.focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
}
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
//! Keep Iced's single-line input from silently filtering pasted control bytes.
|
||||
//! Only the alias field uses this adapter. The child retains editing and focus.
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
|
||||
};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(AliasInput { input, rejected })
|
||||
}
|
||||
|
||||
struct AliasInput<'a, Message, Theme, R> {
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
|
||||
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
|
||||
// previously admitted paste while V is held, until KeyReleased clears that cache.
|
||||
struct CapturedClipboard<'a> {
|
||||
content: Option<String>,
|
||||
original: &'a mut dyn Clipboard,
|
||||
}
|
||||
impl Clipboard for CapturedClipboard<'_> {
|
||||
fn read(&self, kind: clipboard::Kind) -> Option<String> {
|
||||
if kind == clipboard::Kind::Standard {
|
||||
self.content.clone()
|
||||
} else {
|
||||
self.original.read(kind)
|
||||
}
|
||||
}
|
||||
fn write(&mut self, kind: clipboard::Kind, contents: String) {
|
||||
self.original.write(kind, contents);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
|
||||
for AliasInput<'_, Message, Theme, R>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.input.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.input.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(self.input.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.input));
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.input.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let focused = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused();
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
|
||||
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
}
|
||||
let paste = matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
|
||||
if key.to_latin(*physical_key) == Some('v')
|
||||
&& state.modifiers.command() && !state.modifiers.alt());
|
||||
if focused
|
||||
&& paste
|
||||
&& let Some(rejected) = &self.rejected
|
||||
{
|
||||
let content = clipboard.read(clipboard::Kind::Standard);
|
||||
if content
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.chars().any(char::is_control))
|
||||
{
|
||||
// Do not invoke the child: even an empty replacement would erase
|
||||
// its selection. Approval invalidation is an explicit App event.
|
||||
shell.publish(rejected.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
&mut CapturedClipboard {
|
||||
content,
|
||||
original: clipboard,
|
||||
},
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
|
||||
// Escape branch, which also resets stored modifiers. Observe that branch
|
||||
// after its text/shortcut early returns and effective-key conversion.
|
||||
if focused
|
||||
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
|
||||
&& !tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused()
|
||||
{
|
||||
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.input.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::keyboard::{
|
||||
Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Signal {
|
||||
Input(String),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
struct ClipboardText {
|
||||
text: String,
|
||||
reads: Cell<usize>,
|
||||
}
|
||||
impl Clipboard for ClipboardText {
|
||||
fn read(&self, _: clipboard::Kind) -> Option<String> {
|
||||
self.reads.set(self.reads.get() + 1);
|
||||
Some(self.text.clone())
|
||||
}
|
||||
fn write(&mut self, _: clipboard::Kind, _: String) {}
|
||||
}
|
||||
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
|
||||
|
||||
struct Mounted {
|
||||
input: Element<'static, Signal, iced::Theme, FontLayout>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
clipboard: ClipboardText,
|
||||
messages: Vec<Signal>,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
|
||||
let mut child = iced::widget::text_input("Alias", "send_water");
|
||||
if enabled {
|
||||
child = child.on_input(Signal::Input);
|
||||
}
|
||||
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let node = input.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
|
||||
);
|
||||
if focused {
|
||||
let state = tree.children[0].state.downcast_mut::<InputState>();
|
||||
state.focus();
|
||||
state.select_range(0, 10);
|
||||
}
|
||||
let mut mounted = Self {
|
||||
input,
|
||||
tree,
|
||||
node,
|
||||
clipboard: ClipboardText {
|
||||
text: "foo\nbar".into(),
|
||||
reads: Cell::new(0),
|
||||
},
|
||||
messages: vec![],
|
||||
};
|
||||
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
modifiers,
|
||||
)));
|
||||
mounted
|
||||
}
|
||||
fn state(&self) -> &InputState {
|
||||
self.tree.children[0].state.downcast_ref::<InputState>()
|
||||
}
|
||||
fn event(&mut self, event: Event) -> bool {
|
||||
let mut shell = Shell::new(&mut self.messages);
|
||||
self.input.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
|
||||
);
|
||||
shell.is_event_captured()
|
||||
}
|
||||
}
|
||||
fn key(key: Key, modified_key: Key, code: Code) -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key: Physical::Code(code),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn paste() -> Event {
|
||||
key(
|
||||
Key::Character("v".into()),
|
||||
Key::Character("v".into()),
|
||||
Code::KeyV,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
|
||||
for focused in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::COMMAND,
|
||||
Modifiers::COMMAND | Modifiers::ALT,
|
||||
Modifiers::COMMAND | Modifiers::SHIFT,
|
||||
] {
|
||||
let mut mounted = Mounted::new(focused, enabled, modifiers);
|
||||
let before = mounted.state().cursor();
|
||||
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
|
||||
let captured = mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
if expected {
|
||||
vec![Signal::Rejected]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
if expected {
|
||||
assert!(captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_escape_reset_follows_child_after_early_returns() {
|
||||
// Raw Escape mapped to Home must not clear only the guard's Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Escape),
|
||||
Key::Named(Named::Home),
|
||||
Code::Escape,
|
||||
));
|
||||
assert!(mounted.state().is_focused());
|
||||
assert!(mounted.event(paste()));
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
|
||||
// Effective Escape clears both states; a later focus operation does not
|
||||
// invent a new ModifiersChanged event or revive the previous Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
));
|
||||
assert!(!mounted.state().is_focused());
|
||||
mounted.tree.children[0]
|
||||
.state
|
||||
.downcast_mut::<InputState>()
|
||||
.focus();
|
||||
mounted.event(paste());
|
||||
assert!(mounted.messages.is_empty());
|
||||
assert_eq!(mounted.clipboard.reads.get(), 0);
|
||||
|
||||
// Printable text returns before effective Escape and leaves modifiers live.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
let mut event = key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
);
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
|
||||
*text = Some("x".into());
|
||||
}
|
||||
mounted.event(event);
|
||||
assert!(mounted.state().is_focused());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_paste_cache_contains_only_previously_admitted_text() {
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.clipboard.text = "water".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.clipboard.text = "other".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwater".into())],
|
||||
"Iced reuses its first admitted paste while the key is held"
|
||||
);
|
||||
mounted.messages.clear();
|
||||
let before = mounted.state().cursor();
|
||||
mounted.clipboard.text = "bad\tvalue".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
mounted.messages.clear();
|
||||
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
key: Key::Character("v".into()),
|
||||
modified_key: Key::Character("v".into()),
|
||||
physical_key: Physical::Code(Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
}));
|
||||
mounted.clipboard.text = "later".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwaterlater".into())]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Display-only reuse after a guarded alias rename. This is not model authority.
|
||||
use iced::{advanced::text::Renderer, widget::text_editor};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Replace exact, unchanged-line-count source in an existing editor.
|
||||
///
|
||||
/// The caller must first authorize/commit the model change and must reconstruct
|
||||
/// from accepted source on false. Preflight failures do not mutate the editor;
|
||||
/// a failed final readback can leave it changed. On changing success the cursor
|
||||
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
|
||||
/// Direct Content actions deliberately bypass App draft edits and Undo history.
|
||||
pub(super) fn replace<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
before: &str,
|
||||
after: &str,
|
||||
) -> bool {
|
||||
if editor.text() != before {
|
||||
return false;
|
||||
}
|
||||
let Some(old_lines) = lines(before) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new_lines) = lines(after) else {
|
||||
return false;
|
||||
};
|
||||
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
|
||||
return false;
|
||||
}
|
||||
let mut patches = Vec::new();
|
||||
for (line, ((old, old_ending), (new, new_ending))) in
|
||||
old_lines.into_iter().zip(new_lines).enumerate()
|
||||
{
|
||||
let Some(actual) = editor.line(line) else {
|
||||
return false;
|
||||
};
|
||||
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
|
||||
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
|
||||
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
|
||||
return false;
|
||||
}
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
let mut start = old
|
||||
.bytes()
|
||||
.zip(new.bytes())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let mut suffix = old[start..]
|
||||
.bytes()
|
||||
.rev()
|
||||
.zip(new[start..].bytes().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
|
||||
{
|
||||
suffix -= 1;
|
||||
}
|
||||
// All positions and owned payloads are prepared before the first edit.
|
||||
patches.push((
|
||||
line,
|
||||
start,
|
||||
old.len() - suffix,
|
||||
Arc::new(new[start..new.len() - suffix].to_owned()),
|
||||
));
|
||||
}
|
||||
if patches.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let empty = Arc::new(String::new());
|
||||
for (line, start, end, replacement) in patches.into_iter().rev() {
|
||||
select(editor, line, start, end);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
replacement,
|
||||
)));
|
||||
}
|
||||
// Iced overwrites its earliest-change marker on each Edit. An explicit
|
||||
// zero-width selection + empty Paste changes no bytes, but marks line zero.
|
||||
// This also refreshes previously unhighlighted lines before the first patch.
|
||||
// It resets only line zero's text cache; other unchanged lines retain theirs.
|
||||
select(editor, 0, 0, 0);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
|
||||
editor.text() == after
|
||||
}
|
||||
|
||||
fn select<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
line: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) {
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line,
|
||||
column: start,
|
||||
},
|
||||
// move_to(None) does not clear a previous selection in this Iced version.
|
||||
selection: Some(text_editor::Position { line, column: end }),
|
||||
});
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
|
||||
use text_editor::LineEnding;
|
||||
let mut result = Vec::new();
|
||||
let mut parts = source.split('\n').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
let (text, ending) = if parts.peek().is_none() {
|
||||
(part, LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, LineEnding::CrLf)
|
||||
} else {
|
||||
(part, LineEnding::Lf)
|
||||
};
|
||||
if text.contains('\r')
|
||||
|| (text.is_empty()
|
||||
&& ending == LineEnding::CrLf
|
||||
&& result
|
||||
.last()
|
||||
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
result.push((text, ending));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{layout, text::Highlighter, widget::Tree};
|
||||
use iced::{Element, Font, Length, Size};
|
||||
use std::{cell::RefCell, ops::Range};
|
||||
|
||||
#[test]
|
||||
fn exact_text_matrix_and_reverse_preserve_endings() {
|
||||
for (before, after) in [
|
||||
("", ""),
|
||||
("", "alias"),
|
||||
("alias", ""),
|
||||
("same", "same"),
|
||||
("a\n", "alias\n"),
|
||||
("a\r\n", "alias\r\n"),
|
||||
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
|
||||
("\na\n\n", "\nlong_alias\n\n"),
|
||||
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
|
||||
(
|
||||
"雨🙂 e\u{301} @a @a // a",
|
||||
"雨🙂 e\u{301} @a_long @a_long // a",
|
||||
),
|
||||
("@abc", "@abcdef"),
|
||||
("@abcdef", "@abc"),
|
||||
("@abc_suffix", "@def_suffix"),
|
||||
("@none", "@identifier"),
|
||||
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
|
||||
// not be used as String split positions, even outside alias scope.
|
||||
("雨xé", "雪xê"),
|
||||
("é", "©"),
|
||||
(
|
||||
"unchanged\n@a\nunchanged\n@a\n",
|
||||
"unchanged\n@long\nunchanged\n@long\n",
|
||||
),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
assert!(
|
||||
replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), after);
|
||||
let expected = text_editor::Content::<FontLayout>::with_text(after);
|
||||
assert_eq!(editor.line_count(), expected.line_count());
|
||||
for (actual, expected) in editor.lines().zip(expected.lines()) {
|
||||
assert_eq!(actual.text, expected.text);
|
||||
assert_eq!(actual.ending, expected.ending);
|
||||
}
|
||||
if before != after {
|
||||
assert_eq!(
|
||||
editor.cursor().position,
|
||||
text_editor::Position { line: 0, column: 0 }
|
||||
);
|
||||
assert_eq!(editor.cursor().selection, None);
|
||||
}
|
||||
assert!(replace(&mut editor, after, before));
|
||||
assert_eq!(editor.text(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
|
||||
for (actual, before, after) in [
|
||||
("live", "stale", "changed"),
|
||||
("a\nb", "a\nb", "long\nb\nc"),
|
||||
("a\nb", "a\nb", "long\r\nb"),
|
||||
// Refusal on a later line must not apply the earlier planned edit.
|
||||
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
|
||||
("a\rb", "a\rb", "long\rb"),
|
||||
("a\n\rb", "a\n\rb", "long\n\rb"),
|
||||
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
|
||||
// The replacement must not introduce a greedy LFCR boundary either.
|
||||
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
|
||||
("a", "a", "long\r"),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
|
||||
select(&mut editor, 0, 0, 1);
|
||||
let cursor = editor.cursor();
|
||||
let selection = editor.selection();
|
||||
assert!(
|
||||
!replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), actual);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.selection(), selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
|
||||
select(&mut editor, 0, 1, 2);
|
||||
let cursor = editor.cursor();
|
||||
assert!(replace(&mut editor, "@a", "@a"));
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert!(replace(&mut editor, "@a", "@long"));
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
|
||||
assert_eq!(editor.text(), "@long!");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Tracked(crate::highlight::ArcadiaHighlighter);
|
||||
impl Highlighter for Tracked {
|
||||
type Settings = Arc<str>;
|
||||
type Highlight = oa_core::language::TokenClass;
|
||||
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
|
||||
fn new(settings: &Self::Settings) -> Self {
|
||||
Self(crate::highlight::ArcadiaHighlighter::new(settings))
|
||||
}
|
||||
fn update(&mut self, settings: &Self::Settings) {
|
||||
self.0.update(settings);
|
||||
}
|
||||
fn change_line(&mut self, line: usize) {
|
||||
CHANGES.with(|changes| changes.borrow_mut().push(line));
|
||||
self.0.change_line(line);
|
||||
}
|
||||
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
|
||||
self.0.highlight_line(line)
|
||||
}
|
||||
fn current_line(&self) -> usize {
|
||||
self.0.current_line()
|
||||
}
|
||||
}
|
||||
|
||||
fn view(
|
||||
editor: &text_editor::Content<FontLayout>,
|
||||
source: Arc<str>,
|
||||
) -> Element<'_, (), iced::Theme, FontLayout> {
|
||||
text_editor(editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<Tracked>(source, crate::highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
|
||||
let before = "// earlier\n// unmodified\n@a";
|
||||
let after = "// earlier\n// unmodified\n@none";
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
tree
|
||||
};
|
||||
CHANGES.with(|changes| changes.borrow_mut().clear());
|
||||
assert!(replace(&mut editor, before, after));
|
||||
{
|
||||
let mut widget = view(&editor, Arc::from(after));
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
}
|
||||
// Font/metrics/bounds are already stable. This notification must come
|
||||
// from the helper's empty edit, not a fresh renderer initialization.
|
||||
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
|
||||
assert_eq!(editor.text(), after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Combined App-boundary oracles: the workers are completed independently and
|
||||
//! no returned Task is polled. Rendering measurements use the real reveal path.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
#[path = "navigation_union_tests/palette_union.rs"]
|
||||
mod palette_union;
|
||||
|
||||
fn model_id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn navigation_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let entry = source.files.get_mut("project.arcadia").unwrap();
|
||||
*entry = entry.replace(
|
||||
"include \"uses.arcadia\";",
|
||||
"include \"uses.arcadia\"; include \"views.arcadia\";",
|
||||
);
|
||||
source.files.insert(
|
||||
"views.arcadia".into(),
|
||||
concat!(
|
||||
"// Keep @pump and the exact distant placement.\r\n",
|
||||
"diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n",
|
||||
"node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n",
|
||||
).into(),
|
||||
);
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source.clone(), LIMITS).unwrap();
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(
|
||||
!validate(app.project())
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error)
|
||||
);
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
seek_pump(&mut app);
|
||||
app.canvas_size = Some(Size::new(1200., 700.));
|
||||
app.zoom = 0.8;
|
||||
app.pan = Vector::new(31., 47.);
|
||||
app
|
||||
}
|
||||
|
||||
fn seek_pump(app: &mut App) {
|
||||
let source = app.source_workspace.text();
|
||||
let offset = source.rfind("pump;").unwrap();
|
||||
app.source_editor
|
||||
.move_to(cursor(source, offset, offset + 4));
|
||||
}
|
||||
|
||||
fn source_completion(app: &App, request: nav::Request) -> nav::Completion {
|
||||
let snapshot = Arc::new(
|
||||
app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()),
|
||||
);
|
||||
let results = snapshot
|
||||
.query(query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action: request.action,
|
||||
})
|
||||
.unwrap();
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, Arc::new(results))),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagram_completion(app: &App) -> usage::Completion {
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let process = app.behavior.topology.usage_snapshot().into_query_state();
|
||||
usage::Completion {
|
||||
token: panel.token,
|
||||
context: panel.context,
|
||||
result: Ok(usage::collect(
|
||||
app.project(),
|
||||
panel.context.subject,
|
||||
&process,
|
||||
&behavior::scenario::State::default(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_lookup(app: &mut App) -> (Id, Usage) {
|
||||
let completion = diagram_completion(app);
|
||||
send(app, Message::DiagramUsagesReady(completion));
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
assert!(!panel.loading);
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
let found = panel
|
||||
.usages
|
||||
.iter()
|
||||
.find(|u| {
|
||||
u.surface == Surface::Saved(model_id(8))
|
||||
&& u.target == UsageTarget::Placement(model_id(9))
|
||||
})
|
||||
.expect("the exact placed pump is represented")
|
||||
.clone();
|
||||
assert_eq!(found.subject, model_id(2));
|
||||
assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.));
|
||||
(panel.token, found)
|
||||
}
|
||||
|
||||
fn navigate(app: &mut App) -> Usage {
|
||||
let message = app.diagram_usage_message(model_id(2));
|
||||
send(app, message);
|
||||
let (token, found) = finish_lookup(app);
|
||||
send(
|
||||
app,
|
||||
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
|
||||
);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(model_id(8)));
|
||||
assert_eq!(app.selected, model_id(2));
|
||||
assert_eq!(app.selected_node, Some(model_id(9)));
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
found
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Navigation {
|
||||
page: Page,
|
||||
selected: Id,
|
||||
placement: Option<Id>,
|
||||
diagram: Option<Id>,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
pending: Option<(usage::Context, Usage, u64)>,
|
||||
focus: Option<(usage::Context, Usage, Id)>,
|
||||
canvas_epoch: u64,
|
||||
camera_epoch: u64,
|
||||
source_interaction: Id,
|
||||
source_reveal: Option<Id>,
|
||||
}
|
||||
|
||||
fn navigation(app: &App) -> Navigation {
|
||||
Navigation {
|
||||
page: app.page,
|
||||
selected: app.selected,
|
||||
placement: app.selected_node,
|
||||
diagram: app.active_diagram,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
pending: app.pending_diagram_reveal.clone(),
|
||||
focus: app
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.map(|f| (f.context, f.usage.clone(), f.selected)),
|
||||
canvas_epoch: app.canvas_epoch,
|
||||
camera_epoch: app.camera_epoch,
|
||||
source_interaction: app.source_symbols.interaction,
|
||||
source_reveal: app.source_reveal_ticket(),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_measurement(app: &mut App, found: &Usage) {
|
||||
let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap();
|
||||
let (context, epoch) = (*context, *epoch);
|
||||
let actual = Size::new(500., 300.);
|
||||
send(app, Message::DiagramRevealViewport(context, epoch, actual));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.canvas_size, Some(actual));
|
||||
assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05);
|
||||
assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05);
|
||||
assert_eq!(app.selected, model_id(2));
|
||||
assert_eq!(app.selected_node, Some(model_id(9)));
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() {
|
||||
let mut app = navigation_fixture();
|
||||
let original = evidence(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let queued = app.diagram_usage_message(model_id(2));
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let confirm = preview(&mut app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let approved = (
|
||||
form.invocation,
|
||||
form.generation,
|
||||
form.approval.as_ref().unwrap().id,
|
||||
);
|
||||
send(&mut app, queued);
|
||||
let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
|
||||
send(&mut app, source);
|
||||
let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, definition);
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_navigation_available());
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
form.invocation,
|
||||
form.generation,
|
||||
form.approval.as_ref().unwrap().id
|
||||
),
|
||||
approved
|
||||
);
|
||||
assert_eq!(form.replacement, "send_water");
|
||||
assert_eq!(evidence(&app), original);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
send(&mut app, Message::Escape);
|
||||
send(&mut app, confirm);
|
||||
assert!(!app.source_rename.open());
|
||||
assert_eq!(evidence(&app), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() {
|
||||
for finish in [false, true] {
|
||||
let mut app = navigation_fixture();
|
||||
let before = evidence(&app);
|
||||
let queued_rename = app.source_rename_message();
|
||||
let open = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, open);
|
||||
if finish {
|
||||
finish_lookup(&mut app);
|
||||
}
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let loading = panel.loading;
|
||||
assert!(app.rename_admission().is_err());
|
||||
assert!(!app.source_navigation_available());
|
||||
send(&mut app, queued_rename);
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
assert_eq!((panel.token, panel.loading), (token, loading));
|
||||
assert_eq!(evidence(&app), before);
|
||||
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
|
||||
let rename = app.source_rename_message();
|
||||
send(&mut app, rename);
|
||||
assert!(
|
||||
app.source_rename.open(),
|
||||
"the dismissed chooser no longer owns input"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() {
|
||||
for preview_pending in [false, true] {
|
||||
let mut app = navigation_fixture();
|
||||
let before = evidence(&app);
|
||||
let completion = if preview_pending {
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let job = start_preview(&mut app);
|
||||
preview_completion(&app, job)
|
||||
} else {
|
||||
let rename = app.source_rename_message();
|
||||
send(&mut app, rename);
|
||||
target_completion(&app, current_job(&app))
|
||||
};
|
||||
let occupied = completion.job.id;
|
||||
event(&mut app, Event::Close(completion.job.invocation));
|
||||
assert_eq!(app.source_rename.in_flight, Some(occupied));
|
||||
let found = navigate(&mut app);
|
||||
let location = navigation(&app);
|
||||
assert_eq!(evidence(&app), before);
|
||||
let mut forged = completion.clone();
|
||||
forged.job.id = Id::new_v4();
|
||||
send(&mut app, Message::SourceRenameReady(forged));
|
||||
assert_eq!(app.source_rename.in_flight, Some(occupied));
|
||||
assert_eq!(navigation(&app), location);
|
||||
send(&mut app, Message::SourceRenameReady(completion.clone()));
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert!(!app.source_rename.open());
|
||||
assert_eq!(navigation(&app), location);
|
||||
assert_eq!(evidence(&app), before);
|
||||
send(&mut app, Message::SourceRenameReady(completion));
|
||||
assert_eq!(
|
||||
navigation(&app),
|
||||
location,
|
||||
"duplicate completion is passive too"
|
||||
);
|
||||
first_measurement(&mut app, &found);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged()
|
||||
{
|
||||
let mut app = navigation_fixture();
|
||||
let before = evidence(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let queued_begin = app.source_rename_message();
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let confirm = preview(&mut app);
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
event(&mut app, Event::Close(invocation));
|
||||
let found = navigate(&mut app);
|
||||
let location = navigation(&app);
|
||||
for stale in [
|
||||
queued_begin,
|
||||
Message::SourceRename(Event::Close(invocation)),
|
||||
confirm,
|
||||
] {
|
||||
send(&mut app, stale);
|
||||
assert_eq!(navigation(&app), location);
|
||||
assert_eq!(evidence(&app), before);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() {
|
||||
let mut app = navigation_fixture();
|
||||
let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, source);
|
||||
let request = app.source_symbols.request.clone().unwrap();
|
||||
let source_ready = source_completion(&app, request.clone());
|
||||
let rename = app.source_rename_message();
|
||||
send(&mut app, rename);
|
||||
let rename_ready = target_completion(&app, current_job(&app));
|
||||
let rename_job = rename_ready.job.id;
|
||||
event(&mut app, Event::Close(rename_ready.job.invocation));
|
||||
let open = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, open);
|
||||
let old_diagram = diagram_completion(&app);
|
||||
let diagram_job = old_diagram.token;
|
||||
let old_document = app.canvas_document;
|
||||
let project = app.project().clone();
|
||||
app.reset(project, None);
|
||||
assert_ne!(app.canvas_document, old_document);
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.source_rename.in_flight, Some(rename_job));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.diagram_usages.is_none());
|
||||
let before = evidence(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let new_open = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, new_open);
|
||||
let new_token = app.diagram_usages.as_ref().unwrap().token;
|
||||
assert_ne!(new_token, diagram_job);
|
||||
assert_eq!(
|
||||
app.diagram_lookup_in_flight,
|
||||
Some(diagram_job),
|
||||
"replacement waits for old worker"
|
||||
);
|
||||
send(&mut app, Message::SourceRenameReady(rename_ready));
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
|
||||
send(&mut app, Message::SourceSymbolsReady(source_ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_symbols.open);
|
||||
send(&mut app, Message::DiagramUsagesReady(old_diagram));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
|
||||
assert!(app.diagram_usages.as_ref().unwrap().loading);
|
||||
finish_lookup(&mut app);
|
||||
assert_eq!(evidence(&app), before);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert!(!app.source_rename.open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() {
|
||||
let mut app = navigation_fixture();
|
||||
let baseline = app.document.snapshot();
|
||||
let project = app.project().id;
|
||||
app.document
|
||||
.execute(Command::Rename(project, "Previously accepted name".into()))
|
||||
.unwrap();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
seek_pump(&mut app);
|
||||
let before = app.document.snapshot();
|
||||
let mut expected_project = app.project().clone();
|
||||
expected_project
|
||||
.elements
|
||||
.get_mut(&model_id(2))
|
||||
.unwrap()
|
||||
.alias = "send_water".into();
|
||||
let mut expected_source = app.document.bundle().clone();
|
||||
let defs = expected_source.files.get_mut("defs.arcadia").unwrap();
|
||||
*defs = defs.replace("oa_activity pump ", "oa_activity send_water ");
|
||||
let uses = expected_source.files.get_mut("uses.arcadia").unwrap();
|
||||
*uses = uses
|
||||
.replace("source @pump;", "source @send_water;")
|
||||
.replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;");
|
||||
let views = expected_source.files.get_mut("views.arcadia").unwrap();
|
||||
*views = views.replace("element @pump;", "element @send_water;");
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let confirm = preview(&mut app);
|
||||
send(&mut app, confirm);
|
||||
assert_eq!(app.project(), &expected_project);
|
||||
assert_eq!(app.document.bundle(), &expected_source);
|
||||
let renamed = app.document.snapshot();
|
||||
let accepted = evidence(&app);
|
||||
let queued_undo_rename = app
|
||||
.source_rename_undo_message()
|
||||
.expect("fresh rename offers its guarded undo");
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
|
||||
send(&mut app, message);
|
||||
let request = app
|
||||
.source_symbols
|
||||
.request
|
||||
.clone()
|
||||
.expect("renamed exact caret remains queryable");
|
||||
let ready = source_completion(&app, request);
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
let (token, found) = finish_lookup(&mut app);
|
||||
send(
|
||||
&mut app,
|
||||
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
|
||||
);
|
||||
assert_eq!(app.selected, model_id(2));
|
||||
assert_eq!(app.selected_node, Some(model_id(9)));
|
||||
let destination = navigation(&app);
|
||||
send(&mut app, queued_undo_rename);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(evidence(&app), accepted);
|
||||
first_measurement(&mut app, &found);
|
||||
assert_eq!(evidence(&app), accepted);
|
||||
assert!(Arc::ptr_eq(&renamed, &app.document.snapshot()));
|
||||
for (message, expected) in [
|
||||
(Message::Undo, before.clone()),
|
||||
(Message::Redo, renamed.clone()),
|
||||
(Message::Undo, before.clone()),
|
||||
(Message::Undo, baseline.clone()),
|
||||
(Message::Redo, before),
|
||||
(Message::Redo, renamed),
|
||||
] {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
Arc::ptr_eq(&expected, &app.document.snapshot()),
|
||||
"exact history entry restored"
|
||||
);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), expected.bundle);
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Interactions absent from the independently published navigation branch.
|
||||
use super::*;
|
||||
|
||||
fn assert_stale_navigation_message(message: Message) {
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
let status = app.status.clone();
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_old_source_query_and_find_close_preserve_the_new_destination() {
|
||||
let mut previous = navigation_fixture();
|
||||
let query = previous.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(
|
||||
&mut previous,
|
||||
Message::SourceNavigation(nav::Event::Open(false)),
|
||||
);
|
||||
assert!(previous.source_navigation.open);
|
||||
let close = previous.source_find_close_message();
|
||||
assert!(previous.source_input_envelope(&close));
|
||||
assert_stale_navigation_message(query);
|
||||
assert_stale_navigation_message(close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_source_query_and_checked_find_close_remain_live() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
|
||||
assert!(app.source_navigation.open);
|
||||
let close = app.source_find_close_message();
|
||||
send(&mut app, close.clone());
|
||||
assert!(!app.source_navigation.open);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, close);
|
||||
assert_eq!(app.allocation_drag.epoch, epoch, "retired close is inert");
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, message);
|
||||
assert!(app.source_symbols.in_flight.is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_old_chooser_context_and_every_token_variant_are_inert() {
|
||||
let mut previous = navigation_fixture();
|
||||
let open = previous.diagram_usage_message(model_id(2));
|
||||
send(&mut previous, open.clone());
|
||||
let (token, found) = finish_lookup(&mut previous);
|
||||
assert_stale_navigation_message(open);
|
||||
for event in [
|
||||
usage::Event::Close(token),
|
||||
usage::Event::Search(token, "old filter".into()),
|
||||
usage::Event::Page(token, 1),
|
||||
usage::Event::Move(token, 1),
|
||||
usage::Event::Activate(token),
|
||||
usage::Event::Destination(token, Some(found.surface)),
|
||||
usage::Event::Choose(token, found),
|
||||
] {
|
||||
assert_stale_navigation_message(Message::DiagramUsage(event));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_chooser_open_and_close_keep_their_boundary_and_worker_semantics() {
|
||||
let mut app = navigation_fixture();
|
||||
navigate(&mut app);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let message = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, message);
|
||||
assert!(app.diagram_usages.is_some());
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.diagram_navigation_focus.is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let completion = diagram_completion(&app);
|
||||
let token = completion.token;
|
||||
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(token));
|
||||
send(&mut app, Message::DiagramUsagesReady(completion));
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
assert!(
|
||||
app.diagram_usages.is_none(),
|
||||
"completion does not reopen a closed chooser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
let notify = || Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary);
|
||||
send(&mut app, notify());
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, notify());
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
first_measurement(&mut app, &found);
|
||||
send(&mut app, notify());
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation() {
|
||||
let mut app = navigation_fixture();
|
||||
let captured = app.source_rename_begin();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::SourceRename(Event::Begin(captured)));
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Prior scenario", None)
|
||||
.unwrap();
|
||||
let mut previous = App::with_project(project);
|
||||
previous.page = Page::Behavior;
|
||||
previous.behavior.active_owner = Some(scenario);
|
||||
previous
|
||||
.behavior
|
||||
.scenario
|
||||
.enter(previous.canvas_document, scenario);
|
||||
let scope = behavior::canvas_workspace::Scope::input_owner(&previous).unwrap();
|
||||
let captured = behavior::canvas_workspace::intent(
|
||||
scope,
|
||||
"Fit",
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Zoom(
|
||||
previous.scenario_context().unwrap(),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, captured);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Real paragraph advances and clipping through the actual preview dialog.
|
||||
//! Native screenshots additionally check raster ink and the platform renderer.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
|
||||
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct Glyph {
|
||||
text: String,
|
||||
paragraph_y: u32,
|
||||
line: usize,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
|
||||
struct GlyphRecorder {
|
||||
wanted: BTreeSet<String>,
|
||||
transforms: Vec<Transformation>,
|
||||
clips: Vec<Rectangle>,
|
||||
glyphs: Vec<Glyph>,
|
||||
quads: Vec<Rectangle>,
|
||||
}
|
||||
impl GlyphRecorder {
|
||||
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
|
||||
Self {
|
||||
wanted,
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clips: vec![viewport],
|
||||
glyphs: vec![],
|
||||
quads: vec![],
|
||||
}
|
||||
}
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for GlyphRecorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clips = vec![bounds];
|
||||
self.glyphs.clear();
|
||||
self.quads.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
|
||||
self.quads.push(quad.bounds * self.transform());
|
||||
}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for GlyphRecorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let text = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !self.wanted.contains(&text) {
|
||||
return;
|
||||
}
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
if run.text[glyph.start..glyph.end]
|
||||
.chars()
|
||||
.all(char::is_whitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bounds = Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform();
|
||||
self.glyphs.push(Glyph {
|
||||
text: text.clone(),
|
||||
paragraph_y: position.y.to_bits(),
|
||||
line: run.line_i,
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds,
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(
|
||||
&mut self,
|
||||
editor: &Self::Editor,
|
||||
position: Point,
|
||||
color: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
|
||||
}
|
||||
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
|
||||
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollGeometry {
|
||||
id: Option<widget::Id>,
|
||||
bounds: Option<Rectangle>,
|
||||
content: Option<Rectangle>,
|
||||
offset: Vector,
|
||||
}
|
||||
impl widget::Operation for ScrollGeometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == self.id.as_ref() {
|
||||
self.bounds = Some(bounds);
|
||||
self.content = Some(content);
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn normalized(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
|
||||
text.split('\n')
|
||||
.enumerate()
|
||||
.flat_map(|(line, text)| {
|
||||
text.char_indices()
|
||||
.filter(|(_, ch)| !ch.is_whitespace())
|
||||
.map(move |(byte, _)| (line, byte))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declarations(long: bool) -> App {
|
||||
let name = if long {
|
||||
format!("Assess {} request", "界🚀".repeat(90))
|
||||
} else {
|
||||
"Assess 救援 request".into()
|
||||
};
|
||||
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
|
||||
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
|
||||
]) };
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "救援/assess.arcadia".into();
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.show_source_file(false);
|
||||
let start = app
|
||||
.source_workspace
|
||||
.text()
|
||||
.find("oa_activity assess")
|
||||
.unwrap()
|
||||
+ "oa_activity ".len();
|
||||
app.source_editor.move_to(cursor(
|
||||
app.source_workspace.text(),
|
||||
start,
|
||||
start + "assess".len(),
|
||||
));
|
||||
begin(&mut app);
|
||||
let replacement = if long {
|
||||
"a".repeat(255)
|
||||
} else {
|
||||
"assess_damage".into()
|
||||
};
|
||||
input(&mut app, &replacement);
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn assert_page_glyphs_visible(app: &App, host: Size) {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let ready = form.approval.as_ref().unwrap();
|
||||
let file = &ready.preview.files()[form.file];
|
||||
let mut expected = BTreeMap::<String, usize>::new();
|
||||
for line in file.hunks()[form.hunk]
|
||||
.lines()
|
||||
.iter()
|
||||
.skip(form.line)
|
||||
.take(LINES_PER_PAGE)
|
||||
{
|
||||
for source in [
|
||||
line.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
line.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
] {
|
||||
if let Some(fragment) = preview::text_segment(source, form.segment) {
|
||||
let text = normalized(fragment);
|
||||
if !required_chars(&text).is_empty() {
|
||||
*expected.entry(text).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
let viewport = Rectangle::new(Point::ORIGIN, host);
|
||||
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
|
||||
let editor = text_editor::Content::<GlyphRecorder>::new();
|
||||
let mut view = dialog_content(app, &editor);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
|
||||
let mut geometry = ScrollGeometry {
|
||||
id: Some(preview_scroll_id(form, ready.id)),
|
||||
..ScrollGeometry::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
let bounds = geometry.bounds.unwrap();
|
||||
let content = geometry.content.unwrap();
|
||||
assert!(bounds.height >= 40.);
|
||||
let maximum = (content.height - bounds.height).max(0.);
|
||||
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
|
||||
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
|
||||
let mut reserved_bar_gap = true;
|
||||
for step in 0..=steps {
|
||||
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
assert!(
|
||||
(geometry.offset.y - offset).abs() <= 0.5,
|
||||
"actual scroll operation reaches the requested source offset"
|
||||
);
|
||||
renderer::Renderer::reset(&mut renderer, viewport);
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
// The vertical scrollbar's actual painted thumb fixes its excluded x
|
||||
// region; the outer scroll clip alone cannot detect overpainting.
|
||||
let scrollbar_left = renderer
|
||||
.quads
|
||||
.iter()
|
||||
.filter(|quad| {
|
||||
quad.width > 0.
|
||||
&& quad.width <= 16.
|
||||
&& quad.height > 0.
|
||||
&& quad.x >= bounds.x + bounds.width - 18.
|
||||
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
|
||||
&& quad.y >= bounds.y - 0.5
|
||||
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
|
||||
})
|
||||
.map(|quad| quad.x)
|
||||
.min_by(f32::total_cmp);
|
||||
if maximum > 0. {
|
||||
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
|
||||
reserved_bar_gap &= content.x + content.width <= left - 7.5;
|
||||
}
|
||||
for glyph in &renderer.glyphs {
|
||||
let right = glyph.bounds.x + glyph.bounds.width;
|
||||
if let Some(left) = scrollbar_left {
|
||||
assert!(
|
||||
right <= left - 0.5,
|
||||
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
|
||||
glyph.text,
|
||||
glyph.bytes,
|
||||
glyph.bounds
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
|
||||
"source glyph escaped reserved width: {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds
|
||||
);
|
||||
assert!(
|
||||
glyph.bounds.x >= glyph.clip.x - 0.5
|
||||
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
|
||||
"source glyph horizontally clipped: {:?} {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds,
|
||||
glyph.clip
|
||||
);
|
||||
if glyph.bounds.y >= glyph.clip.y - 0.5
|
||||
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
|
||||
{
|
||||
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
|
||||
let chars = logical_line
|
||||
.char_indices()
|
||||
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
|
||||
.map(|(byte, _)| (glyph.line, byte));
|
||||
visible
|
||||
.entry((glyph.text.clone(), glyph.paragraph_y))
|
||||
.or_default()
|
||||
.extend(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reserved_bar_gap,
|
||||
"source content must reserve actual scrollbar width plus gap"
|
||||
);
|
||||
assert!(
|
||||
bounds.width >= host.width - 30.,
|
||||
"preview must use available dialog width: {bounds:?} / {host:?}"
|
||||
);
|
||||
for (text, count) in expected {
|
||||
let required = required_chars(&text);
|
||||
let instances: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|((value, _), _)| value == &text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
instances.len(),
|
||||
count,
|
||||
"all before/after occurrences must be seen: {text:?}"
|
||||
);
|
||||
for (_, seen) in instances {
|
||||
assert_eq!(
|
||||
seen, &required,
|
||||
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
|
||||
for long in [false, true] {
|
||||
let mut app = declarations(long);
|
||||
let ready = app
|
||||
.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
let before = evidence(&app);
|
||||
for (width, height) in [(1100., 700.), (580., 700.)] {
|
||||
app.window_size = Size::new(width, height);
|
||||
let host = Size::new((width - 84.).min(708.), height - 120.);
|
||||
let total = segment_total(app.source_rename.form.as_ref().unwrap());
|
||||
if long {
|
||||
assert!(
|
||||
total >= 3,
|
||||
"include unbroken ASCII and wide Unicode across segments"
|
||||
);
|
||||
}
|
||||
for segment in 0..total {
|
||||
assert_page_glyphs_visible(&app, host);
|
||||
if segment + 1 < total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, true));
|
||||
}
|
||||
}
|
||||
for _ in 1..total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, false));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
evidence(&app),
|
||||
before,
|
||||
"preview geometry/paging cannot alter transaction inputs"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
ready.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Execute the controller's scroll operations on a retained, scrolled dialog.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
const HOST: Size = Size::new(720., 660.);
|
||||
|
||||
fn paged_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let name = "Long engineering name 雨 ".repeat(18);
|
||||
source.files.get_mut("defs.arcadia").unwrap().replace_range(
|
||||
..,
|
||||
&format!(
|
||||
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
|
||||
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
|
||||
),
|
||||
);
|
||||
let mut uses = String::new();
|
||||
for index in 0..16 {
|
||||
if index == 12 {
|
||||
uses.push_str(&"// unchanged gap\r\n".repeat(10));
|
||||
}
|
||||
uses.push_str(&format!(
|
||||
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
|
||||
));
|
||||
}
|
||||
source.files.insert("uses.arcadia".into(), uses);
|
||||
let mut app = fixture();
|
||||
app.window_size = HOST;
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
|
||||
app.source_editor
|
||||
.move_to(cursor(app.source_workspace.text(), start, start + 4));
|
||||
begin(&mut app);
|
||||
input(&mut app, "pump_long");
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn scroll_id(app: &App) -> widget::Id {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
|
||||
}
|
||||
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
editor: text_editor::Content<FontLayout>,
|
||||
tree: widget::Tree,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(app: &App) -> Self {
|
||||
let editor = text_editor::Content::<FontLayout>::new();
|
||||
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
|
||||
Self { editor, tree }
|
||||
}
|
||||
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
// Preserve the actual scroll state across the new page's view diff.
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
|
||||
}
|
||||
fn offset(&mut self, app: &App) -> f32 {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
}
|
||||
fn scroll_down(&mut self, app: &App) -> f32 {
|
||||
self.operate(
|
||||
app,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
scroll_id(app),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(10_000.),
|
||||
},
|
||||
),
|
||||
);
|
||||
let offset = self.offset(app);
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
||||
let mut count = 0;
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("preview paging must only schedule a widget operation");
|
||||
};
|
||||
self.operate(app, operation.as_mut());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let old_id = scroll_id(&app);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_ne!(old_id, scroll_id(&app));
|
||||
// Negative control: even after diff/layout with the new id and new file,
|
||||
// Iced retains the previous offset until the returned operation executes.
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
|
||||
for navigation in [
|
||||
Event::Line(invocation, approval, true),
|
||||
Event::Segment(invocation, approval, true),
|
||||
Event::Hunk(invocation, approval, true),
|
||||
Event::File(invocation, approval, false),
|
||||
Event::Search(invocation, "uses".into()),
|
||||
Event::Search(invocation, "no-path-match".into()),
|
||||
Event::Search(invocation, String::new()),
|
||||
] {
|
||||
mounted.scroll_down(&app);
|
||||
let task = app.update(Message::SourceRename(navigation));
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
// Passive redraw and unchanged filter input preserve deliberate scrolling.
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
String::new(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Input(
|
||||
invocation,
|
||||
"pump_long".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"clamped first-page request is a no-op"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let old_approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let old_page_task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
let next_page_task = app.update(Message::SourceRename(Event::Line(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
|
||||
|
||||
let old_approval_task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"defs".into(),
|
||||
)));
|
||||
input(&mut app, "pump_new");
|
||||
let _ = preview(&mut app); // Real new approval in the same invocation/page.
|
||||
assert_ne!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
old_approval
|
||||
);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old approval target is absent"
|
||||
);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"stale envelope is rejected before scheduling"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
|
||||
let first_visit = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let next_page = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page).await, 1);
|
||||
let return_visit = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, return_visit).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, first_visit).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old visit to the identical page is stale"
|
||||
);
|
||||
|
||||
let old_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"uses".into(),
|
||||
)));
|
||||
let new_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"us".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, new_filter).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_filter).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old filter on the same page is stale"
|
||||
);
|
||||
|
||||
input(&mut app, "pump_new");
|
||||
let job = start_preview(&mut app);
|
||||
let completion = preview_completion(&app, job);
|
||||
// No intermediate frame mounts the temporary no-approval view. The old
|
||||
// Scrollable survives even though the new approval has a different id.
|
||||
let ready = app.update(Message::SourceRenameReady(completion));
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, ready).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
|
||||
{
|
||||
// Exercise both an actually mounted Details view and a fast round trip
|
||||
// between frames, where Iced retains the same preview Scrollable state.
|
||||
for mount_details in [true, false] {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let held_reset = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let old_preview = scroll_id(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let page = (form.file, form.hunk, form.line, form.segment);
|
||||
|
||||
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
||||
assert_eq!(mounted.execute(&app, show_details).await, 0);
|
||||
let details_visit = scroll_id(&app);
|
||||
assert_ne!(details_visit, old_preview);
|
||||
if mount_details {
|
||||
let mut hidden = Geometry {
|
||||
id: old_preview.clone(),
|
||||
offset: None,
|
||||
};
|
||||
mounted.operate(&app, &mut hidden);
|
||||
assert!(
|
||||
hidden.offset.is_none(),
|
||||
"Details actually replaced the preview"
|
||||
);
|
||||
}
|
||||
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
||||
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
|
||||
assert_eq!(
|
||||
scroll_id(&app),
|
||||
details_visit,
|
||||
"unchanged visibility keeps its visit"
|
||||
);
|
||||
|
||||
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, show_preview).await,
|
||||
0,
|
||||
"returning does not force a scroll reset"
|
||||
);
|
||||
let returned_visit = scroll_id(&app);
|
||||
assert_ne!(returned_visit, old_preview);
|
||||
assert_ne!(returned_visit, details_visit);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
|
||||
assert_eq!(form.approval.as_ref().unwrap().id, approval);
|
||||
|
||||
let offset = mounted.scroll_down(&app);
|
||||
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
|
||||
assert_eq!(scroll_id(&app), returned_visit);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"repeated Preview preserves deliberate scroll"
|
||||
);
|
||||
assert_eq!(mounted.execute(&app, held_reset).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"the previous visit cannot reset the returned preview"
|
||||
);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
|
||||
//! Child of source_rename_timing_tests, preserving the separate real App test.
|
||||
use super::*;
|
||||
use crate::{highlight, views::source_tests::FontLayout};
|
||||
use iced::advanced::widget::operation::Focusable;
|
||||
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{
|
||||
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
|
||||
};
|
||||
|
||||
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
|
||||
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
|
||||
#[derive(Default)]
|
||||
struct ObservedFontLayout {
|
||||
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
|
||||
}
|
||||
impl renderer::Renderer for ObservedFontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {
|
||||
self.drawn = None;
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for ObservedFontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
|
||||
self.drawn = Some((editor.downgrade(), position, clip));
|
||||
}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
type Content = text_editor::Content<ObservedFontLayout>;
|
||||
const HOST: Size = Size::new(640., 350.);
|
||||
|
||||
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
|
||||
text_editor(editor)
|
||||
.on_action(|_| ())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
|
||||
assert_eq!(editor.text(), expected);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
let lines: Vec<_> = expected.split('\n').collect();
|
||||
assert_eq!(editor.line_count(), lines.len());
|
||||
for (index, part) in lines.iter().enumerate() {
|
||||
let (text, ending) = if index + 1 == lines.len() {
|
||||
(*part, text_editor::LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, text_editor::LineEnding::CrLf)
|
||||
} else {
|
||||
(*part, text_editor::LineEnding::Lf)
|
||||
};
|
||||
let actual = editor.line(index).unwrap();
|
||||
assert_eq!(actual.text, text);
|
||||
assert_eq!(actual.ending, ending);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
|
||||
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
|
||||
let editor = weak.upgrade().expect("drawn editor is still live");
|
||||
let buffer = editor.buffer();
|
||||
assert_eq!(weak.bounds, Size::new(608., 318.));
|
||||
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
|
||||
assert_eq!(buffer.metrics().font_size, 14.);
|
||||
assert_eq!(
|
||||
buffer.scroll().horizontal,
|
||||
0.,
|
||||
"fixture origin fits the viewport"
|
||||
);
|
||||
let start = cursor.position;
|
||||
let end = cursor.selection.expect("approved alias selection");
|
||||
assert_eq!(start.line, end.line);
|
||||
assert_eq!(end.column - start.column, alias.len());
|
||||
let line = &buffer.lines[start.line];
|
||||
assert_eq!(&line.text()[start.column..end.column], alias);
|
||||
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
|
||||
let font = graphics::text::to_attributes(Font::MONOSPACE);
|
||||
for byte in start.column..end.column {
|
||||
let attrs = line.attrs_list().get_span(byte);
|
||||
assert_eq!(
|
||||
attrs.color_opt,
|
||||
Some(color),
|
||||
"accepted alias syntax attributes"
|
||||
);
|
||||
assert_eq!(attrs.family, font.family);
|
||||
}
|
||||
let mut covered = vec![false; alias.len()];
|
||||
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
|
||||
for glyph in run.glyphs {
|
||||
let from = glyph.start.max(start.column);
|
||||
let to = glyph.end.min(end.column);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
|
||||
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
|
||||
let y = position.y + run.line_top;
|
||||
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
|
||||
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
|
||||
covered[from - start.column..to - start.column].fill(true);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
covered.into_iter().all(|seen| seen),
|
||||
"entire alias has visible shaped glyphs"
|
||||
);
|
||||
// This temporary strong upgrade is dropped before the next editor mutation.
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Arm {
|
||||
Constructor,
|
||||
Reuse,
|
||||
}
|
||||
impl Arm {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Constructor => "with_text",
|
||||
Self::Reuse => "same_line_reuse",
|
||||
}
|
||||
}
|
||||
fn phases(self) -> [&'static str; 4] {
|
||||
match self {
|
||||
Self::Constructor => [
|
||||
"constructor_replace_origin",
|
||||
"constructor_next_layout",
|
||||
"constructor_highlight_draw",
|
||||
"constructor_combined",
|
||||
],
|
||||
Self::Reuse => [
|
||||
"reuse_replace_origin",
|
||||
"reuse_next_layout",
|
||||
"reuse_highlight_draw",
|
||||
"reuse_combined",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trial(
|
||||
fixture: &Fixture,
|
||||
cursor: text_editor::Cursor,
|
||||
arm: Arm,
|
||||
trial: usize,
|
||||
samples: &mut Samples,
|
||||
) {
|
||||
let before = &fixture.bundle.files[ORIGIN];
|
||||
let after = &fixture.expected_bundle.files[ORIGIN];
|
||||
let mut editor = Content::with_text(before);
|
||||
editor.move_to(fixture.cursor);
|
||||
let mut renderer = ObservedFontLayout::default();
|
||||
let theme = iced::Theme::Light;
|
||||
let defaults = renderer::Style::default();
|
||||
let viewport = Rectangle::with_size(HOST);
|
||||
let limits = layout::Limits::new(Size::ZERO, HOST);
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before.as_str()));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
tree.state
|
||||
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
|
||||
.focus();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
tree
|
||||
};
|
||||
check_text(&editor, before, fixture.cursor);
|
||||
check_draw(&renderer, fixture.cursor, OLD);
|
||||
// No recorder Weak/Strong from setup may perturb replacement ownership.
|
||||
renderer.drawn = None;
|
||||
let next_source: Arc<str> = Arc::from(after.as_str());
|
||||
|
||||
// Only normal operations and four timestamps occur between start and end.
|
||||
// Source/model oracles, logging and recorder glyph inspection are outside.
|
||||
let started = Instant::now();
|
||||
let replaced = match arm {
|
||||
Arm::Constructor => {
|
||||
editor = Content::with_text(after);
|
||||
true
|
||||
}
|
||||
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
|
||||
};
|
||||
editor.move_to(cursor);
|
||||
let replaced_at = Instant::now();
|
||||
let mut widget = view(&editor, next_source);
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
let laid_out_at = Instant::now();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let drawn_at = Instant::now();
|
||||
drop(widget);
|
||||
|
||||
assert!(replaced, "reuse must not silently time a fallback/refusal");
|
||||
check_text(&editor, after, cursor);
|
||||
check_draw(&renderer, cursor, NEW);
|
||||
renderer.drawn = None;
|
||||
for (phase, duration) in arm.phases().into_iter().zip([
|
||||
replaced_at.duration_since(started),
|
||||
laid_out_at.duration_since(replaced_at),
|
||||
drawn_at.duration_since(laid_out_at),
|
||||
drawn_at.duration_since(started),
|
||||
]) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
|
||||
"trial": trial, "warmup": trial == 0,
|
||||
"elapsed_ns": duration.as_nanos().to_string(),
|
||||
"elapsed_ms": duration.as_secs_f64() * 1000.0,
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
samples.0.entry(phase).or_default().push(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
|
||||
let fixture = Fixture::load();
|
||||
// Resolve the exact production approval and verify full Project/source once,
|
||||
// outside all intervals. The actual App Confirm evaluator remains separate.
|
||||
let cursor = {
|
||||
let app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
|
||||
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
|
||||
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
|
||||
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
|
||||
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
|
||||
})
|
||||
);
|
||||
let mut samples = Samples::default();
|
||||
for index in 0..=5 {
|
||||
let arms = if index % 2 == 0 {
|
||||
[Arm::Constructor, Arm::Reuse]
|
||||
} else {
|
||||
[Arm::Reuse, Arm::Constructor]
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
|
||||
);
|
||||
for arm in arms {
|
||||
trial(&fixture, cursor, arm, index, &mut samples);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Manual timing through the real Document and App paths. No disk writes,
|
||||
//! executor polling, layout, GPU work or first-paint measurement occurs here.
|
||||
//! Register as a test child of app::source_rename to use its actual worker helpers.
|
||||
use super::*;
|
||||
use crate::source_workspace::Workspace;
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[path = "source_rename_layout_timing_tests.rs"]
|
||||
mod layout_timing_tests;
|
||||
|
||||
const OLD: &str = "oa_restore_work";
|
||||
const NEW: &str = "oa_restore_pressure";
|
||||
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
|
||||
const ORIGIN: &str = "traceability.arcadia";
|
||||
|
||||
struct Fixture {
|
||||
bundle: SourceBundle,
|
||||
project: Project,
|
||||
expected_bundle: SourceBundle,
|
||||
expected_project: Project,
|
||||
target: Id,
|
||||
cursor: text_editor::Cursor,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn load() -> Self {
|
||||
// The optional entry selects a parent-pinned copy of this exact fixture,
|
||||
// not an arbitrary performance generator or a mutable GUI destination.
|
||||
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/release1/project.arcadia")
|
||||
});
|
||||
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
|
||||
let bundle = loaded.reachable_bundle();
|
||||
assert_eq!(
|
||||
bundle.files.len(),
|
||||
30,
|
||||
"use the preserved release1 walkthrough"
|
||||
);
|
||||
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no stabilization/migration"
|
||||
);
|
||||
assert!(!document.dirty());
|
||||
let project = document.project().clone();
|
||||
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
|
||||
assert_eq!(project.elements[&target].alias, OLD);
|
||||
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
|
||||
let mut expected_project = project.clone();
|
||||
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
|
||||
|
||||
// Independent literal source oracle, authored before running the rename.
|
||||
// Do not replace the alias substring in filenames or the trace's alias.
|
||||
let mut expected_bundle = bundle.clone();
|
||||
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
|
||||
assert_eq!(
|
||||
declarations.matches("oa_activity oa_restore_work ").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
|
||||
*declarations = declarations
|
||||
.replace(
|
||||
"oa_activity oa_restore_work ",
|
||||
"oa_activity oa_restore_pressure ",
|
||||
)
|
||||
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
|
||||
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
|
||||
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
|
||||
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
|
||||
let source = &bundle.files[ORIGIN];
|
||||
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
|
||||
let position = |offset| {
|
||||
let prefix = &source[..offset];
|
||||
text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix
|
||||
.rsplit_once('\n')
|
||||
.map_or(prefix.len(), |(_, tail)| tail.len()),
|
||||
}
|
||||
};
|
||||
let cursor = text_editor::Cursor {
|
||||
position: position(start),
|
||||
selection: Some(position(start + OLD.len())),
|
||||
};
|
||||
assert!(nav::exact_cursor(source, cursor));
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
|
||||
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
|
||||
"identities": project.identities().len(), "active_file": ORIGIN,
|
||||
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
|
||||
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
|
||||
})
|
||||
);
|
||||
Self {
|
||||
bundle,
|
||||
project,
|
||||
expected_bundle,
|
||||
expected_project,
|
||||
target,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn app(&self) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = ORIGIN.into();
|
||||
app.selected = app.project().id;
|
||||
app.page = Page::Text;
|
||||
app.sync();
|
||||
app.source_editor.move_to(self.cursor);
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.rename_admission().is_ok());
|
||||
app
|
||||
}
|
||||
|
||||
fn check_candidate(&self, prepared: &PreparedAliasRename) {
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.before().bundle, self.bundle);
|
||||
assert_eq!(prepared.before().decoded.project, self.project);
|
||||
assert_eq!(prepared.after().bundle, self.expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, self.expected_project);
|
||||
}
|
||||
|
||||
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
|
||||
assert!(
|
||||
std::ptr::eq(app.document.compiled(), candidate_address),
|
||||
"adopt exact candidate allocation"
|
||||
);
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
|
||||
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert!(
|
||||
!app.document.dirty(),
|
||||
"live known saved baseline remains authoritative"
|
||||
);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"exactly one preceding model transaction"
|
||||
);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
|
||||
|
||||
impl Samples {
|
||||
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
|
||||
let started = Instant::now();
|
||||
let result = action();
|
||||
let elapsed = started.elapsed();
|
||||
// Printing, assertions and dropping the returned value are outside this
|
||||
// interval. Full Confirm still includes production's own local drops.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "timing", "phase": phase, "trial": trial,
|
||||
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
|
||||
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
self.0.entry(phase).or_default().push(elapsed);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn report(mut self) {
|
||||
for (phase, values) in &mut self.0 {
|
||||
assert_eq!(values.len(), 5);
|
||||
values.sort();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "summary", "phase": phase, "samples": values.len(),
|
||||
"min_ms": values[0].as_secs_f64() * 1000.0,
|
||||
"median_ms": values[2].as_secs_f64() * 1000.0,
|
||||
"max_ms": values[4].as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(app: &App, context: &Context) -> Target {
|
||||
let snapshot = app
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap();
|
||||
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
|
||||
resolve_target(&snapshot, context, offset).unwrap()
|
||||
}
|
||||
|
||||
fn active_job(app: &App) -> Job {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let id = form.pending.unwrap();
|
||||
assert_eq!(app.source_rename.in_flight, Some(id));
|
||||
Job {
|
||||
id,
|
||||
invocation: form.invocation,
|
||||
generation: form.generation,
|
||||
context: form.context.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
assert_eq!(target.id, fixture.target);
|
||||
let request = samples
|
||||
.time("document_request_mint", trial, || {
|
||||
app.document.alias_rename_request(target.id, NEW.into())
|
||||
})
|
||||
.unwrap();
|
||||
let prepared = samples
|
||||
.time("worker_document_prepare", trial, || request.prepare())
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = samples
|
||||
.time("worker_preview_and_origin", trial, || {
|
||||
prepare_approval(prepared, &target, ORIGIN)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(approval.preview.files().len(), 2);
|
||||
assert_eq!(approval.preview.changed_line_count(), 6);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
// Same-destination save completion is a legitimate live update after Preview.
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let changed = samples
|
||||
.time("document_guarded_commit", trial, || {
|
||||
app.document.commit_alias_rename(&approval.prepared)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(changed);
|
||||
samples.time("app_sync", trial, || app.sync());
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let begin = app.source_rename_message();
|
||||
// These Tasks are deliberately not polled: deterministic worker helpers
|
||||
// supply the same results through the real readiness messages below.
|
||||
drop(app.update(begin));
|
||||
let target_job = active_job(&app);
|
||||
let target = Arc::new(resolve(&app, &target_job.context));
|
||||
assert_eq!(target.id, fixture.target);
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: target_job,
|
||||
payload: Payload::Target(Ok(target.clone())),
|
||||
})));
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
|
||||
let generation = app.source_rename.form.as_ref().unwrap().generation;
|
||||
drop(app.update(Message::SourceRename(Event::Preview(
|
||||
invocation, generation,
|
||||
))));
|
||||
let preview_job = active_job(&app);
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
|
||||
drop(target);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
let expected_cursor = approval.cursor;
|
||||
let approval_id = approval.id;
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: preview_job,
|
||||
payload: Payload::Preview(Ok(approval)),
|
||||
})));
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
approval_id
|
||||
);
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
|
||||
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
|
||||
samples.time("confirm_returned_task_drop_without_polling", trial, || {
|
||||
drop(task)
|
||||
});
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert!(app.source_rename_undo_message().is_some());
|
||||
assert_eq!(app.source_editor.cursor(), expected_cursor);
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
|
||||
let fixture = Fixture::load();
|
||||
let mut samples = Samples::default();
|
||||
for trial in 0..=5 {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "record": "order", "trial": trial,
|
||||
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
|
||||
);
|
||||
if trial % 2 == 0 {
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
} else {
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -31,6 +31,24 @@ fn admission_notice(error: query::AdmissionError) -> String {
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SourceSymbols(context, _) => {
|
||||
context.session == self.source_workspace.session()
|
||||
&& context.path == self.source_workspace.active
|
||||
}
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
context == &self.source_symbol_context()
|
||||
&& *instance == self.source_find_instance
|
||||
&& self.source_navigation.open
|
||||
&& self.source_navigation_available()
|
||||
}
|
||||
// The checked Find-close envelope is mapped inside update_inner.
|
||||
Message::SourceNavigation(nav::Event::Close) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_symbol_context(&self) -> nav::SymbolContext {
|
||||
nav::SymbolContext {
|
||||
session: self.source_workspace.session(),
|
||||
@@ -52,7 +70,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
|
||||
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
|
||||
let files: Vec<_> = self
|
||||
.source_workspace
|
||||
.files
|
||||
@@ -83,13 +101,14 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
|
||||
pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
|
||||
snapshot.key() == self.source_key()
|
||||
&& self.with_source_input(|input| snapshot.matches(input))
|
||||
}
|
||||
|
||||
pub fn source_navigation_available(&self) -> bool {
|
||||
self.page == Page::Text
|
||||
&& !self.source_rename.open()
|
||||
&& !self.busy
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
@@ -146,6 +165,10 @@ impl App {
|
||||
&& nav::exact_cursor(self.source_workspace.text(), cursor)
|
||||
{
|
||||
self.queue_source_reveal(cursor, false);
|
||||
// This ticket restores focus to the live editor, not a saved cursor.
|
||||
// Iced's Cursor omits Word/Line selection kind; moving back to that
|
||||
// lossy value would collapse an actual word/line selection.
|
||||
self.source_jump = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,13 +198,14 @@ impl App {
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
return nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
// A valid ticket with no jump is focus-only. All intentional navigation
|
||||
// still takes the cursor branch above, including explicit line selection.
|
||||
nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
|
||||
@@ -442,6 +466,9 @@ impl App {
|
||||
Some("That source location is no longer available. Run navigation again.".into());
|
||||
return;
|
||||
}
|
||||
// The destination is admitted. Capture effective Word/Line ranges only
|
||||
// now, when this editor is actually about to leave its current position.
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if let Some(buffer) = self
|
||||
.source_workspace
|
||||
.files
|
||||
@@ -451,7 +478,7 @@ impl App {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
generation: buffer.generation(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
cursor: departure,
|
||||
};
|
||||
self.source_symbols
|
||||
.back
|
||||
@@ -461,8 +488,7 @@ impl App {
|
||||
}
|
||||
self.source_symbols.back.push(origin);
|
||||
}
|
||||
self.source_workspace
|
||||
.switch(path, self.source_editor.cursor());
|
||||
self.source_workspace.switch(path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
@@ -477,7 +503,7 @@ impl App {
|
||||
return;
|
||||
};
|
||||
let unchanged = origin.generation == buffer.generation();
|
||||
let cursor = if unchanged {
|
||||
let mut cursor = if unchanged {
|
||||
origin.cursor
|
||||
} else if origin.path == self.source_workspace.active {
|
||||
self.source_editor.cursor()
|
||||
@@ -491,8 +517,11 @@ impl App {
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.source_workspace
|
||||
.switch(&origin.path, self.source_editor.cursor());
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if !unchanged && origin.path == self.source_workspace.active {
|
||||
cursor = departure;
|
||||
}
|
||||
self.source_workspace.switch(&origin.path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
self.source_symbols.notice =
|
||||
@@ -505,3 +534,6 @@ impl App {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod focus_selection_tests;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
//! The mounted CPU renderer and App use the same cosmic-text editor engine, but
|
||||
//! Iced's Content is parameterized by renderer and cannot be shared between them.
|
||||
//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret
|
||||
//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions.
|
||||
//! App's own Content is never replaced or cursor-normalized after selection. This
|
||||
//! tests the actual controller/content boundary without a window or GPU; native
|
||||
//! replay remains the check of the complete production renderer/layout path.
|
||||
use crate::app::Page;
|
||||
use crate::app::{App, Message};
|
||||
use crate::document::Document;
|
||||
use crate::source_navigation as nav;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, Shell, layout, widget as core_widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::keyboard::{self, Key, Modifiers};
|
||||
use iced::widget::{button, container, row, text_editor, text_input};
|
||||
use iced::{Element, Event, Font, Size, mouse};
|
||||
use iced::{Point, Task, widget};
|
||||
use oa_core::{Project, source_project::SourceBundle};
|
||||
use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering};
|
||||
|
||||
const SOURCE: &str = concat!(
|
||||
"// oa_contact tail\r\n",
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n",
|
||||
);
|
||||
const HOST: Size = Size::new(620., 260.);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app
|
||||
}
|
||||
|
||||
fn ui<'a>(
|
||||
app: &App,
|
||||
content: &'a text_editor::Content<FontLayout>,
|
||||
) -> Element<'a, Message, iced::Theme, FontLayout> {
|
||||
let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open {
|
||||
row![
|
||||
text_input("Find", "")
|
||||
.id("source-find")
|
||||
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
|
||||
container(button("Close").on_press(app.source_find_close_message()))
|
||||
.id("selection-find-close"),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().into()
|
||||
};
|
||||
// Column::push drops zero-height children. Keep both paths mounted even
|
||||
// while Find is hidden, as the production retained-slot layout does.
|
||||
widget::Column::from_vec(vec![
|
||||
container(find)
|
||||
.height(if app.source_navigation.open { 32. } else { 0. })
|
||||
.into(),
|
||||
widget::text_editor(content)
|
||||
.id(app.source_editor_id())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(iced::Fill)
|
||||
.on_action(Message::SourceEdit)
|
||||
.into(),
|
||||
])
|
||||
.width(iced::Fill)
|
||||
.height(iced::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
content: text_editor::Content<FontLayout>,
|
||||
tree: core_widget::Tree,
|
||||
}
|
||||
|
||||
// Operations follow widget-provided traversal instead of assuming a fixed
|
||||
// relationship between layout nodes, transparent containers and state trees.
|
||||
struct Inspection {
|
||||
editor: core_widget::Id,
|
||||
focused: Option<bool>,
|
||||
close_bounds: Option<iced::Rectangle>,
|
||||
}
|
||||
|
||||
impl core_widget::Operation for Inspection {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
|
||||
operate(self);
|
||||
}
|
||||
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&core_widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
state: &mut dyn core_widget::operation::Focusable,
|
||||
) {
|
||||
if id == Some(&self.editor) {
|
||||
assert!(
|
||||
self.focused.replace(state.is_focused()).is_none(),
|
||||
"duplicate editor ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) {
|
||||
if id == Some(&core_widget::Id::new("selection-find-close")) {
|
||||
assert!(
|
||||
self.close_bounds.replace(bounds).is_none(),
|
||||
"duplicate Close target ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mounted {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
content: text_editor::Content::with_text(SOURCE),
|
||||
tree: core_widget::Tree::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec<Message> {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
for (event, cursor) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
*cursor,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::with_size(HOST),
|
||||
);
|
||||
if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) {
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"mounted input must be handled: {event:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn operation(&mut self, app: &App, mut op: Box<dyn core_widget::Operation>) {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
loop {
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
op.as_mut(),
|
||||
);
|
||||
match op.finish() {
|
||||
core_widget::operation::Outcome::Chain(next) => op = next,
|
||||
core_widget::operation::Outcome::None => break,
|
||||
core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn task(&mut self, app: &App, task: Task<Message>) {
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(op) = action else {
|
||||
panic!("expected a widget operation")
|
||||
};
|
||||
self.operation(app, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect(&mut self, app: &App) -> Inspection {
|
||||
let mut inspection = Inspection {
|
||||
editor: app.source_editor_id(),
|
||||
focused: None,
|
||||
close_bounds: None,
|
||||
};
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
&mut inspection,
|
||||
);
|
||||
inspection
|
||||
}
|
||||
|
||||
fn editor_focused(&mut self, app: &App) -> bool {
|
||||
self.inspect(app)
|
||||
.focused
|
||||
.expect("exactly one editor with the requested ID")
|
||||
}
|
||||
|
||||
fn close_message(&mut self, app: &App, escape: bool) -> Message {
|
||||
if escape {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
self.events(app, &[(event, mouse::Cursor::Unavailable)])
|
||||
.is_empty()
|
||||
);
|
||||
// The existing subscription tests cover the captured-Escape bridge.
|
||||
// Deliver its App message after this actual input captured the key.
|
||||
Message::Escape
|
||||
} else {
|
||||
let center = self
|
||||
.inspect(app)
|
||||
.close_bounds
|
||||
.expect("the mounted Find Close target exists")
|
||||
.center();
|
||||
let cursor = mouse::Cursor::Available(center);
|
||||
let mut messages = self.events(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let close = messages.remove(0);
|
||||
assert!(matches!(close, Message::SourceFindClose(..)));
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, app: &mut App, line: bool) {
|
||||
// One mounted update batch avoids delays between Iced's consecutive-click
|
||||
// timestamps; content actions are applied in their emitted order below.
|
||||
let at = mouse::Cursor::Available(Point::new(65., 25.));
|
||||
let events = (0..if line { 3 } else { 2 })
|
||||
.flat_map(|_| {
|
||||
[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let messages = self.events(app, &events);
|
||||
assert_eq!(messages.len(), if line { 3 } else { 2 });
|
||||
for (index, message) in messages.into_iter().enumerate() {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("editor action")
|
||||
};
|
||||
if index == 0 {
|
||||
assert!(matches!(action, text_editor::Action::Click(_)));
|
||||
self.content.perform(action);
|
||||
let caret = self.content.cursor();
|
||||
assert_eq!(caret.position.line, 0);
|
||||
assert!(
|
||||
(4..13).contains(&caret.position.column),
|
||||
"click must hit inside oa_contact: {caret:?}"
|
||||
);
|
||||
// App's GPU-typed Content has no public layout API. Copy only the
|
||||
// initial hit-tested caret before any Word/Line selection exists.
|
||||
app.source_editor.move_to(caret);
|
||||
} else {
|
||||
assert_eq!(
|
||||
action,
|
||||
if index == 1 {
|
||||
text_editor::Action::SelectWord
|
||||
} else {
|
||||
text_editor::Action::SelectLine
|
||||
}
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
self.content.selection().as_deref(),
|
||||
Some(if line {
|
||||
"// oa_contact tail"
|
||||
} else {
|
||||
"oa_contact"
|
||||
})
|
||||
);
|
||||
assert_eq!(app.source_editor.selection(), self.content.selection());
|
||||
assert!(self.editor_focused(app));
|
||||
}
|
||||
|
||||
fn type_replacement(&mut self, app: &mut App) {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Character("x".into()),
|
||||
modified_key: Key::Character("x".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: Some("x".into()),
|
||||
repeat: false,
|
||||
});
|
||||
let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else {
|
||||
panic!("typed edit")
|
||||
};
|
||||
assert_eq!(
|
||||
action,
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('x'))
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() {
|
||||
for line in [false, true] {
|
||||
for escape in [false, true] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection();
|
||||
let cursor = app.source_editor.cursor();
|
||||
// Word/Line selection has coincident raw endpoints. Merely checking
|
||||
// cursor equality after a move_to roundtrip cannot detect the defect.
|
||||
assert_eq!(cursor.selection, Some(cursor.position));
|
||||
let task = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
let close = mounted.close_message(&app, escape);
|
||||
let _ = app.update(close);
|
||||
let ticket = app
|
||||
.source_reveal_ticket()
|
||||
.expect("guarded restoration ticket");
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection(),
|
||||
selected,
|
||||
"selected bytes must survive focus restoration"
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), expected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() {
|
||||
for line in [false, true] {
|
||||
for old_roundtrip in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
let interior = app.source_editor.cursor();
|
||||
if old_roundtrip {
|
||||
app.source_editor.move_to(interior);
|
||||
assert_ne!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
}
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = if old_roundtrip {
|
||||
let mut text = SOURCE.to_owned();
|
||||
text.insert(interior.position.column, 'x');
|
||||
text
|
||||
} else {
|
||||
SOURCE.replacen(&selected, "x", 1)
|
||||
};
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() {
|
||||
for later_raw_input in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let selected = app.source_editor.selection();
|
||||
let open = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, open).await;
|
||||
let close = app.source_find_close_message();
|
||||
let _ = app.update(close);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let focus = if later_raw_input {
|
||||
app.update(Message::SourceViewport(Some(ticket)))
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, reopen).await;
|
||||
if later_raw_input {
|
||||
app.source_symbols
|
||||
.raw_interaction
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
mounted.task(&app, focus).await;
|
||||
} else {
|
||||
let stale = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, stale).await;
|
||||
}
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let target = text_editor::Cursor {
|
||||
position: text_editor::Position { line: 1, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
// Clear the previous semantic selection as actual show_source_file does when
|
||||
// moving to another source buffer, then exercise intentional navigation.
|
||||
app.show_source_file(false);
|
||||
app.queue_source_reveal(target, true);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert_eq!(app.source_editor.cursor().position, target.position);
|
||||
assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;"));
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use oa_core::source_project::SourceBundle;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
mod departure_selection;
|
||||
|
||||
const DEFINITIONS: &str = "defs/活動.arcadia";
|
||||
const USES: &str = "uses.arcadia";
|
||||
|
||||
@@ -430,8 +432,17 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources
|
||||
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
|
||||
assert!(app.source_symbols.results.is_some());
|
||||
}
|
||||
let results = app.source_symbols.results.clone();
|
||||
app.source_navigation.open = true;
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(app.source_symbols.open && !app.source_navigation.open);
|
||||
match (&results, &app.source_symbols.results) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("Find Escape must retain underlying results"),
|
||||
}
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(!app.source_symbols.open && !app.source_navigation.open);
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.notice.is_none());
|
||||
@@ -776,7 +787,8 @@ async fn find_close_releases_removed_input_and_guarded_focus_respects_later_inpu
|
||||
|
||||
/// Feed the real App subscription recipes the status produced by a mounted
|
||||
/// TextInput. Poll only immediately available output: no timer wait, worker,
|
||||
/// file dialog or App task is executed by this helper.
|
||||
/// file dialog or App task is executed by this helper. Require the canvas keyboard
|
||||
/// notification separately, then return every command routed to an Escape owner.
|
||||
fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
use iced::advanced::{Layout, Shell, layout, subscription, widget};
|
||||
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
|
||||
@@ -836,6 +848,7 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut messages = Vec::new();
|
||||
let mut keyboard_boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
|
||||
let mut context = Context::from_waker(noop_waker_ref());
|
||||
@@ -844,9 +857,20 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
!matches!(message, Message::CheckpointTick),
|
||||
"timer must remain pending"
|
||||
);
|
||||
messages.push(message);
|
||||
if matches!(
|
||||
&message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
keyboard_boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
keyboard_boundaries, 1,
|
||||
"each key has exactly one passive allocation notification"
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
@@ -895,6 +919,16 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
}
|
||||
assert!(app.source_symbols.open && !app.source_navigation.open);
|
||||
match (&results, &app.source_symbols.results) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("Find Escape must retain underlying results"),
|
||||
}
|
||||
let mut messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1, "references still own the next Escape");
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_symbols.open && !app.source_navigation.open);
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none());
|
||||
if pending {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scoped_escape_retains_references_then_closes_them_without_losing_effective_selection() {
|
||||
for line in [false, true] {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, line);
|
||||
let results = finish(&mut app, query::Action::References);
|
||||
app.source_symbols.notice = Some("Keep this reference notice".into());
|
||||
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
|
||||
app.source_navigation.query = "retain 雪".into();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
|
||||
let mut messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.source_navigation.query, "retain 雪");
|
||||
assert!(app.source_symbols.open);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert_eq!(
|
||||
app.source_symbols.notice.as_deref(),
|
||||
Some("Keep this reference notice")
|
||||
);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
|
||||
let mut messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none());
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert!(captured_input_subscription_messages(&app).is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
|
||||
let expected = if line {
|
||||
raw.files[USES].replacen(&selected, "x", 1)
|
||||
} else {
|
||||
raw.files[USES].replacen("source @pump", "source @x", 1)
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
send(&mut app, Message::SourceUndo);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
fn select_effective_pump(app: &mut App, line: bool) -> String {
|
||||
let source = app.source_workspace.text();
|
||||
let offset = source.find("source @pump").unwrap() + "source @pu".len();
|
||||
app.source_editor.move_to(text_editor::Cursor {
|
||||
position: position(source, offset),
|
||||
selection: None,
|
||||
});
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(if line {
|
||||
text_editor::Action::SelectLine
|
||||
} else {
|
||||
text_editor::Action::SelectWord
|
||||
}),
|
||||
);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
assert!(selected.contains("pump"));
|
||||
let raw = app.source_editor.cursor();
|
||||
assert_eq!(raw.selection, Some(raw.position));
|
||||
selected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_selection_survives_file_roundtrip_and_definition_back() {
|
||||
for line in [false, true] {
|
||||
for definition in [false, true] {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, line);
|
||||
let accepted = app.document.snapshot();
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
if definition {
|
||||
let results = finish(&mut app, query::Action::Definition);
|
||||
assert_eq!(results.disposition, query::Disposition::Direct);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_workspace.active, DEFINITIONS);
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
reveal(&mut app);
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
} else {
|
||||
show(&mut app, DEFINITIONS);
|
||||
show(&mut app, USES);
|
||||
}
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_workspace.undo(false) && !app.source_workspace.undo(true));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
let expected = if line {
|
||||
bundle.files[USES].replacen(&selected, "x", 1)
|
||||
} else {
|
||||
bundle.files[USES].replacen("source @pump", "source @x", 1)
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
|
||||
);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_selection_is_untouched_by_noop_missing_invalid_or_cancelled_navigation() {
|
||||
for variant in 0..4 {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, false);
|
||||
let raw_cursor = app.source_editor.cursor();
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
let accepted = app.document.snapshot();
|
||||
match variant {
|
||||
0 => send(&mut app, Message::SourceFile(USES.into())),
|
||||
1 => send(&mut app, Message::SourceFile("missing.arcadia".into())),
|
||||
2 => app.source_symbol_jump(
|
||||
DEFINITIONS,
|
||||
query::Position {
|
||||
line: 999,
|
||||
byte_column: 0,
|
||||
},
|
||||
query::Position {
|
||||
line: 999,
|
||||
byte_column: 1,
|
||||
},
|
||||
),
|
||||
_ => {
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
send(&mut app, Message::SourceFile(USES.into()));
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
reveal(&mut app);
|
||||
}
|
||||
}
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(
|
||||
app.source_editor.cursor(),
|
||||
raw_cursor,
|
||||
"live selection mode must not normalize"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_active_origin_back_keeps_current_effective_selection() {
|
||||
let mut app = fixture();
|
||||
let old_generation = app.source_workspace.files[USES].generation();
|
||||
let old_cursor = app.source_editor.cursor();
|
||||
let original = app.source_workspace.text().to_owned();
|
||||
app.source_editor
|
||||
.perform(text_editor::Action::Move(text_editor::Motion::Left));
|
||||
app.source_editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 3 },
|
||||
selection: None,
|
||||
});
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
|
||||
);
|
||||
let mut expected_edit = original;
|
||||
expected_edit.insert(3, 'x');
|
||||
assert_eq!(app.source_workspace.text(), expected_edit);
|
||||
let selected = select_effective_pump(&mut app, true);
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
app.source_symbols.back.push(nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: USES.into(),
|
||||
generation: old_generation,
|
||||
cursor: old_cursor,
|
||||
});
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
reveal(&mut app);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert!(
|
||||
app.source_symbols
|
||||
.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("draft changed")
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_creation_preserves_outgoing_selection_and_undisplayed_entry_cursor() {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, true);
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let entry_cursor = app.source_workspace.files[&bundle.entry].cursor;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::SourceNewPath("new.arcadia".into()));
|
||||
send(&mut app, Message::SourceCreateFile);
|
||||
assert_eq!(app.source_workspace.active, "new.arcadia");
|
||||
assert_eq!(
|
||||
app.source_workspace.files[&bundle.entry].cursor,
|
||||
entry_cursor
|
||||
);
|
||||
assert_eq!(app.source_workspace.files[USES].text, bundle.files[USES]);
|
||||
assert!(
|
||||
app.source_workspace.files[&bundle.entry]
|
||||
.text
|
||||
.contains("include \"new.arcadia\";")
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
show(&mut app, USES);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
select_effective_pump(&mut app, true);
|
||||
let raw = app.source_editor.cursor();
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceNewPath("../invalid.arcadia".into()),
|
||||
);
|
||||
send(&mut app, Message::SourceCreateFile);
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), raw);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
|
||||
+122
-205
@@ -20,6 +20,7 @@ mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
mod scenario_activation;
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
@@ -1179,9 +1180,10 @@ struct Scene {
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
slots: Vec<SequenceSlot>,
|
||||
rows: BTreeMap<Id, (Id, f32)>,
|
||||
activations: Vec<scenario_activation::Segment>,
|
||||
lifecycle: scenario_lifecycle::render::Rendering,
|
||||
open_calls: Vec<Point>,
|
||||
sequence_bounds: BTreeMap<Id, (f32, f32)>,
|
||||
lifecycle: scenario_lifecycle::render::Rendering,
|
||||
}
|
||||
struct SequenceSlot {
|
||||
slot: scenario::Slot,
|
||||
@@ -1225,6 +1227,11 @@ impl SequenceInput {
|
||||
}
|
||||
}
|
||||
impl Scene {
|
||||
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
||||
id != owner
|
||||
&& (self.boxes.iter().any(|item| item.id == id)
|
||||
|| self.lines.iter().any(|item| item.id == id))
|
||||
}
|
||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
@@ -1238,9 +1245,10 @@ impl Scene {
|
||||
lanes: BTreeMap::new(),
|
||||
slots: vec![],
|
||||
rows: BTreeMap::new(),
|
||||
activations: vec![],
|
||||
lifecycle: scenario_lifecycle::render::Rendering::default(),
|
||||
open_calls: vec![],
|
||||
sequence_bounds: BTreeMap::new(),
|
||||
lifecycle: scenario_lifecycle::render::Rendering::default(),
|
||||
};
|
||||
let mut lanes = p
|
||||
.children(owner)
|
||||
@@ -1338,29 +1346,7 @@ impl Scene {
|
||||
scene.size.height,
|
||||
);
|
||||
scene.lifecycle.align_messages(&mut scene.lines);
|
||||
let calls: Vec<_> = scene
|
||||
.rows
|
||||
.keys()
|
||||
.filter_map(|&id| {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
receiver: Some(receiver),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements[&id].shared()
|
||||
{
|
||||
Some((id, *lane_x.get(receiver)?))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (call, x) in calls {
|
||||
scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None);
|
||||
}
|
||||
scene.layout_activations(p, &lane_x);
|
||||
}
|
||||
for (id, name, detail) in lanes {
|
||||
let x = lane_x[&id];
|
||||
@@ -1379,11 +1365,7 @@ impl Scene {
|
||||
scene.boxes.push(BoxItem {
|
||||
id,
|
||||
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
|
||||
title: if scenario {
|
||||
format!("Lane · {name}")
|
||||
} else {
|
||||
name
|
||||
},
|
||||
title: name,
|
||||
detail,
|
||||
marker: None,
|
||||
});
|
||||
@@ -1427,14 +1409,16 @@ impl Scene {
|
||||
};
|
||||
if scenario
|
||||
&& matches!(body, OccurrenceBody::Exchange {
|
||||
sender: Some(sender), receiver: Some(receiver),
|
||||
message_kind: MessageKind::Create | MessageKind::Delete, ..
|
||||
} if sender == receiver)
|
||||
sender: Some(sender), receiver: Some(receiver),
|
||||
message_kind: MessageKind::Create | MessageKind::Delete, ..
|
||||
} if sender == receiver)
|
||||
{
|
||||
// Self lifecycle elbows and their caption need clearance from
|
||||
// the preceding action/state/reference box, before recording rows.
|
||||
// Record the lifecycle receive row only after making room for
|
||||
// the self-message elbow and caption above its marker.
|
||||
*y += 16.;
|
||||
}
|
||||
#[cfg(test)]
|
||||
scenario_lifecycle::render::apply_test_row_offset(*id, y);
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1654,148 +1638,6 @@ impl Scene {
|
||||
});
|
||||
}
|
||||
}
|
||||
fn activation_segment(
|
||||
&mut self,
|
||||
call: Id,
|
||||
scope: Id,
|
||||
receiver: Id,
|
||||
x: f32,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
) {
|
||||
for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) {
|
||||
self.boxes.push(BoxItem {
|
||||
id: call,
|
||||
rect: Rectangle::new(
|
||||
Point::new(x - 4., span.start),
|
||||
Size::new(8., span.end - span.start),
|
||||
),
|
||||
title: String::new(),
|
||||
detail: String::new(),
|
||||
marker: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Follow a single correlation through the ordered tree. Branches are drawn
|
||||
/// as independent segments, never as one chronological stretch through ALT
|
||||
/// operands. The result says whether a path can leave this sequence open.
|
||||
fn activation_sequence(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
call: Id,
|
||||
x: f32,
|
||||
mut start: Option<f32>,
|
||||
) -> bool {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
receiver: Some(receiver),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements[&call].shared()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let receiver = *receiver;
|
||||
let entered_open = start.is_some();
|
||||
let mut split = false;
|
||||
for id in p.elements[&owner].steps().into_iter().flatten() {
|
||||
let Some(&(_, y)) = self.rows.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if *id == call {
|
||||
start = Some(y);
|
||||
}
|
||||
let Some(top) = start else {
|
||||
continue;
|
||||
};
|
||||
match p.elements[id].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Delete,
|
||||
receiver: Some(deleted),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) if *deleted == receiver => {
|
||||
// This path cannot show activation beyond its local death.
|
||||
// Keep the unresolved-call cue; a Delete is not a Reply.
|
||||
self.activation_segment(call, owner, receiver, x, top, y);
|
||||
self.open_calls.push(Point::new(x + 12., y - 16.));
|
||||
start = None;
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
reply_to: Some(correlation),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) if *correlation == call => {
|
||||
self.activation_segment(call, owner, receiver, x, top, y);
|
||||
for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) {
|
||||
self.boxes.push(BoxItem {
|
||||
id: call,
|
||||
rect: Rectangle::new(
|
||||
Point::new(x - 7., span.start),
|
||||
Size::new(14., span.end - span.start),
|
||||
),
|
||||
title: String::new(),
|
||||
detail: String::new(),
|
||||
marker: None,
|
||||
});
|
||||
}
|
||||
start = None;
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { operator, branches },
|
||||
..
|
||||
}) => {
|
||||
split = true;
|
||||
self.activation_segment(call, owner, receiver, x, top, y);
|
||||
let bottom = self
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|b| b.id == *id && !b.title.is_empty())
|
||||
.map(|b| b.rect.y + b.rect.height)
|
||||
.unwrap_or(y);
|
||||
let outcomes: Vec<_> = branches
|
||||
.iter()
|
||||
.filter_map(|branch| {
|
||||
let &(top, _) = self.sequence_bounds.get(branch)?;
|
||||
Some(self.activation_sequence(p, *branch, call, x, Some(top)))
|
||||
})
|
||||
.collect();
|
||||
let open = match operator {
|
||||
FragmentOperator::Alternative => {
|
||||
outcomes.is_empty() || outcomes.iter().any(|open| *open)
|
||||
}
|
||||
FragmentOperator::Parallel => outcomes.iter().all(|open| *open),
|
||||
// Optional/repeated bodies do not guarantee execution.
|
||||
FragmentOperator::Optional | FragmentOperator::Loop => true,
|
||||
};
|
||||
start = open.then_some(bottom);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(top) = start {
|
||||
let bottom = if entered_open || split {
|
||||
self.sequence_bounds
|
||||
.get(&owner)
|
||||
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
|
||||
} else {
|
||||
top + 26.
|
||||
};
|
||||
self.activation_segment(call, owner, receiver, x, top, bottom);
|
||||
self.open_calls.push(Point::new(x + 8., bottom - 7.));
|
||||
}
|
||||
start.is_some()
|
||||
}
|
||||
fn slot_at(&self, point: Point, parent: Option<Id>) -> Option<&SequenceSlot> {
|
||||
self.slots
|
||||
.iter()
|
||||
@@ -1839,20 +1681,33 @@ impl Scene {
|
||||
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
|
||||
})
|
||||
.map(|b| b.id)
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| {
|
||||
!line.label.is_empty()
|
||||
&& line.points.windows(2).any(|p| {
|
||||
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
|
||||
})
|
||||
})
|
||||
.map(|line| line.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.activations
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.rect.contains(point))
|
||||
.map(|b| b.call)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
|
||||
.map(|line| line.id)
|
||||
})
|
||||
// The generous message tolerance must not steal the exposed
|
||||
// shoulder of another call's execution when the view is zoomed out.
|
||||
.or_else(|| self.near_sequence_item(point))
|
||||
.or_else(|| {
|
||||
self.boxes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.rect.contains(point) && b.title.is_empty())
|
||||
.map(|b| b.id)
|
||||
})
|
||||
.or_else(|| self.lifecycle.lane_at(point, self.scale))
|
||||
.or_else(|| self.lane_at(point))
|
||||
.or_else(|| {
|
||||
@@ -1885,9 +1740,8 @@ impl Scene {
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
(line.id, distance)
|
||||
});
|
||||
// One distance comparison for both kinds of screen-sized hit tolerance.
|
||||
// Dense minimum-Fit rows must not give the first message or last marker
|
||||
// a larger hit priority than the visibly nearer item. UUID breaks ties.
|
||||
// Compare broad message and lifecycle-marker tolerance together after
|
||||
// exact shapes, so dense minimum-Fit rows choose the nearer item.
|
||||
self.lifecycle
|
||||
.nearest_marker(point)
|
||||
.into_iter()
|
||||
@@ -1969,6 +1823,29 @@ impl canvas::Program<Message> for Scene {
|
||||
state.pointer = cursor.position();
|
||||
cursor
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete),
|
||||
modifiers,
|
||||
repeat: false,
|
||||
..
|
||||
}) if editable
|
||||
&& tool == CanvasTool::Select
|
||||
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
&& state.press.is_none()
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.removal_target(context.scenario, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
context,
|
||||
self.selected,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
@@ -2158,6 +2035,55 @@ impl canvas::Program<Message> for Scene {
|
||||
f.translate(self.camera_pan);
|
||||
f.scale(self.scale);
|
||||
self.lifecycle.draw_lanes(&mut f, self.selected);
|
||||
// Guides belong behind execution fills and cards. In particular, a
|
||||
// lifeline must not cut through the middle of its activation rectangle.
|
||||
for guide in self.lines.iter().filter(|line| line.label.is_empty()) {
|
||||
f.stroke(
|
||||
&Path::new(|path| {
|
||||
path.move_to(guide.points[0]);
|
||||
for point in &guide.points[1..] {
|
||||
path.line_to(*point);
|
||||
}
|
||||
}),
|
||||
Stroke::default()
|
||||
.with_color(if guide.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
})
|
||||
.with_width(if guide.id == self.selected { 2.5 } else { 1.2 }),
|
||||
);
|
||||
}
|
||||
// Label backplates clear guides, but must not erase execution borders
|
||||
// or message geometry. Their text remains in the foreground below.
|
||||
for line in self.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
f.fill_rectangle(
|
||||
line.label_at,
|
||||
Size::new(
|
||||
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
|
||||
22.,
|
||||
),
|
||||
Color::WHITE,
|
||||
);
|
||||
}
|
||||
for activation in &self.activations {
|
||||
let path = Path::rectangle(activation.rect.position(), activation.rect.size());
|
||||
f.fill(&path, Color::from_rgb8(243, 248, 248));
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(if activation.call == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
})
|
||||
.with_width(if activation.call == self.selected {
|
||||
2.5
|
||||
} else {
|
||||
1.
|
||||
}),
|
||||
);
|
||||
}
|
||||
for b in &self.boxes {
|
||||
let outline = matches!(
|
||||
b.marker,
|
||||
@@ -2166,7 +2092,7 @@ impl canvas::Program<Message> for Scene {
|
||||
let color = if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
style::BOUNDARY
|
||||
};
|
||||
if outline {
|
||||
f.stroke(
|
||||
@@ -2184,7 +2110,7 @@ impl canvas::Program<Message> for Scene {
|
||||
);
|
||||
}
|
||||
}
|
||||
for l in &self.lines {
|
||||
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let color = if l.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
@@ -2221,11 +2147,6 @@ impl canvas::Program<Message> for Scene {
|
||||
}),
|
||||
Stroke::default().with_color(color),
|
||||
);
|
||||
f.fill_rectangle(
|
||||
l.label_at,
|
||||
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.),
|
||||
Color::WHITE,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: short(&l.label, 32),
|
||||
position: l.label_at,
|
||||
@@ -2237,8 +2158,8 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
for b in &self.boxes {
|
||||
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
|
||||
// Clipped child frames start with an identity transform. Match
|
||||
// the card's pan and scale, as for lifecycle Create labels.
|
||||
// WGPU child clips start with an identity transform, as in
|
||||
// the lifecycle capsule label painter.
|
||||
f.with_clip(clip, |label| {
|
||||
label.translate(self.camera_pan);
|
||||
label.scale(self.scale);
|
||||
@@ -3025,9 +2946,9 @@ mod direct_canvas_tests {
|
||||
p = execute_batch(&p, &commands).unwrap();
|
||||
let scene = Scene::sequence(&p, f.owner, f.call);
|
||||
let bars: Vec<_> = scene
|
||||
.boxes
|
||||
.activations
|
||||
.iter()
|
||||
.filter(|b| b.id == f.call && b.rect.width == 8.)
|
||||
.filter(|b| b.call == f.call)
|
||||
.map(|b| b.rect)
|
||||
.collect();
|
||||
for reply in &replies {
|
||||
@@ -3065,11 +2986,7 @@ mod direct_canvas_tests {
|
||||
#[test]
|
||||
fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() {
|
||||
let (f, scene) = scene();
|
||||
let bar = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|b| b.id == f.call && b.rect.width == 8.)
|
||||
.unwrap();
|
||||
let bar = scene.activations.iter().find(|b| b.call == f.call).unwrap();
|
||||
assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1);
|
||||
assert!(scene.open_calls.is_empty());
|
||||
let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
|
||||
@@ -3077,9 +2994,9 @@ mod direct_canvas_tests {
|
||||
assert_eq!(scene.open_calls.len(), 1);
|
||||
assert_eq!(
|
||||
scene
|
||||
.boxes
|
||||
.activations
|
||||
.iter()
|
||||
.find(|b| b.id == f.call && b.rect.width == 8.)
|
||||
.find(|b| b.call == f.call)
|
||||
.unwrap()
|
||||
.rect
|
||||
.height,
|
||||
|
||||
@@ -10,8 +10,12 @@ pub use control::Control;
|
||||
pub use viewport::Viewport;
|
||||
|
||||
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
|
||||
!app.busy
|
||||
&& !app.hosted.visible
|
||||
!app.busy && keyboard_owner_enabled(app)
|
||||
}
|
||||
|
||||
/// Passive I/O suspends commands without changing the logical input owner.
|
||||
pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
|
||||
!app.hosted.visible
|
||||
&& app.workflow.is_none()
|
||||
&& app.file_prompt.is_none()
|
||||
&& !app.delete_pending
|
||||
@@ -26,7 +30,6 @@ pub fn controls<'a>(
|
||||
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> {
|
||||
@@ -43,17 +46,21 @@ pub fn controls<'a>(
|
||||
publish,
|
||||
)
|
||||
};
|
||||
row![
|
||||
let controls = 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()
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
tooltip(
|
||||
controls,
|
||||
container(text("F6: enter/leave canvas keyboard controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom,
|
||||
).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -121,7 +128,6 @@ pub enum Event {
|
||||
CancelGesture,
|
||||
Pan(Vector),
|
||||
Zoom { factor: f32, at: Point, base: f32 },
|
||||
Focus,
|
||||
Traverse(bool),
|
||||
ControlFocus(&'static str),
|
||||
ToggleFocus,
|
||||
|
||||
@@ -70,7 +70,6 @@ impl<T: Send + 'static> Operation<T> for Collect {
|
||||
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,
|
||||
|
||||
@@ -236,6 +236,9 @@ impl Mounted {
|
||||
}
|
||||
}
|
||||
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
|
||||
self.pixels_at(app, mouse::Cursor::Unavailable)
|
||||
}
|
||||
pub fn pixels_at(&mut self, app: &App, cursor: mouse::Cursor) -> Vec<u8> {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
@@ -247,7 +250,7 @@ impl Mounted {
|
||||
let mut messages = vec![];
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
mouse::Cursor::Unavailable,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
@@ -262,7 +265,7 @@ impl Mounted {
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: crate::style::INK,
|
||||
},
|
||||
mouse::Cursor::Unavailable,
|
||||
cursor,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
Headless::screenshot(
|
||||
@@ -625,7 +628,7 @@ pub(crate) async fn retained_toolbar_boundaries(
|
||||
use iced::keyboard::key::Named;
|
||||
for boundary in ["Chooser", "Dirty", "Picker"] {
|
||||
let mut packets = vec![];
|
||||
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
|
||||
for name in ["Fit", tool, "Details"] {
|
||||
packets.push(capture_toolbar(mounted, app, name));
|
||||
}
|
||||
let lifetime = app.camera_epoch;
|
||||
|
||||
@@ -10,6 +10,7 @@ use iced::{keyboard, mouse};
|
||||
struct Input {
|
||||
scope: Option<Scope>,
|
||||
focused: bool,
|
||||
focus_pending: bool,
|
||||
pointer: Option<Point>,
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
@@ -21,9 +22,11 @@ impl operation::Focusable for Input {
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
self.focus_pending = false;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.focus_pending = false;
|
||||
self.pan = None;
|
||||
self.space = false;
|
||||
}
|
||||
@@ -92,6 +95,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
state.focus_pending = false;
|
||||
}
|
||||
state.scope = Some(self.scope);
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
@@ -143,6 +147,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
// A traversal operation is applied after this Iced event batch. Keep
|
||||
// its focus witness, but retire keyboard admission immediately.
|
||||
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
|
||||
return;
|
||||
}
|
||||
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
|
||||
state.bounds = Some(layout.bounds());
|
||||
if changed_bounds || !self.keyboard {
|
||||
@@ -151,6 +160,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
state.focus_pending = false;
|
||||
}
|
||||
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
@@ -178,6 +188,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focused = false;
|
||||
state.focus_pending = false;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorLeft) => {
|
||||
state.pan = None;
|
||||
@@ -190,6 +201,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
cancel = true;
|
||||
}
|
||||
state.focused = inside && self.keyboard;
|
||||
state.focus_pending = false;
|
||||
if inside
|
||||
&& self.keyboard
|
||||
&& (*button == mouse::Button::Middle
|
||||
@@ -242,6 +254,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
repeat: false,
|
||||
..
|
||||
}) if self.keyboard && modifiers.is_empty() => {
|
||||
state.focus_pending = true;
|
||||
camera = Some(CameraEvent::ToggleFocus);
|
||||
capture = true;
|
||||
cancel = true;
|
||||
@@ -258,12 +271,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Tab) => {
|
||||
state.focus_pending = true;
|
||||
camera = Some(CameraEvent::Traverse(modifiers.shift()));
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Escape) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focus_pending = true;
|
||||
camera = Some(CameraEvent::Leave);
|
||||
capture = true;
|
||||
}
|
||||
@@ -309,7 +324,9 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
|
||||
if matches!(event, Event::Keyboard(_))
|
||||
&& (!state.focused || !self.keyboard || state.pan.is_some() || state.space)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let child_cursor = if inside {
|
||||
|
||||
@@ -694,9 +694,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
|
||||
let title = match picker {
|
||||
Picker::Owner if staged_owner => "Or choose an existing participant",
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
@@ -707,16 +705,13 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
if let Some(id) = selected {
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
|
||||
@@ -67,6 +67,34 @@ impl std::fmt::Display for MessageStyle {
|
||||
})
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn removal_test_point(app: &App, owner: Id, id: Id) -> Point {
|
||||
let scene = super::Scene::sequence(app.project(), owner, id);
|
||||
let point = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.id == id && !item.title.is_empty())
|
||||
.map(|item| Point::new(item.rect.x + 20., item.rect.y + 20.))
|
||||
.or_else(|| {
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id && !line.label.is_empty())
|
||||
.map(|line| line.label_at + iced::Vector::new(6., 8.))
|
||||
})
|
||||
.expect("rendered scenario target");
|
||||
let presentation = &app.behavior.scenario.presentation;
|
||||
Point::ORIGIN
|
||||
+ presentation.pan
|
||||
+ iced::Vector::new(point.x, point.y) * presentation.scale(scene.size)
|
||||
}
|
||||
|
||||
pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool {
|
||||
super::selected_behavior(p, id) == Some(owner)
|
||||
&& super::Scene::sequence(p, owner, id).removal_target(owner, id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
@@ -103,12 +131,12 @@ pub enum CanvasEvent {
|
||||
FormShown(Context, Id),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Shelf(Context, Shelf),
|
||||
OpenUse(Context, Id),
|
||||
Back(Context),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
Remove(Context, Id),
|
||||
Begin {
|
||||
context: Context,
|
||||
slot: Slot,
|
||||
@@ -142,12 +170,12 @@ impl CanvasEvent {
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
| Self::Back(context)
|
||||
| Self::Choose(context, _)
|
||||
| Self::Select(context, _)
|
||||
| Self::Remove(context, _)
|
||||
| Self::Begin { context, .. }
|
||||
| Self::BeginOccurrence { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
@@ -291,7 +319,6 @@ 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,
|
||||
@@ -330,7 +357,6 @@ impl State {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
identity: Some((document, owner)),
|
||||
focused: self.presentation.focused,
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
@@ -365,12 +391,6 @@ impl State {
|
||||
true
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
self.presentation.focused
|
||||
}
|
||||
pub(crate) fn toggle_focus(&mut self) {
|
||||
self.presentation.focused = !self.presentation.focused;
|
||||
}
|
||||
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
|
||||
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
|
||||
}
|
||||
@@ -566,7 +586,7 @@ impl State {
|
||||
}
|
||||
created(command, draft.parent)
|
||||
}
|
||||
pub(super) fn canvas_tool(&self, owner: Id) -> CanvasTool {
|
||||
pub(crate) fn canvas_tool(&self, owner: Id) -> CanvasTool {
|
||||
self.canvas_tool
|
||||
.filter(|(id, _)| *id == owner)
|
||||
.map(|(_, tool)| tool)
|
||||
@@ -1935,7 +1955,7 @@ pub(crate) mod direct_tests {
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
||||
pub(crate) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
||||
let mut f = fixture();
|
||||
let p = &mut f.project;
|
||||
let machine = p
|
||||
@@ -3234,12 +3254,45 @@ mod tests {
|
||||
.iter()
|
||||
.any(|l| l.id == steps[1] && l.dashed && l.label.contains("reply"))
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.any(|b| b.id == steps[0] && b.rect.width == 8.)
|
||||
assert_eq!(scene.activations.len(), 1);
|
||||
let activation = &scene.activations[0];
|
||||
assert_eq!(activation.call, steps[0]);
|
||||
assert_eq!(activation.receiver, receiver);
|
||||
assert_eq!(activation.scope, owner);
|
||||
assert_eq!(
|
||||
activation.finish,
|
||||
scenario_activation::Finish::Reply(steps[1])
|
||||
);
|
||||
// Scene::draw paints this typed rectangle directly. Check its extent
|
||||
// against the actual message endpoints, without fixing a style width.
|
||||
let rect = activation.rect;
|
||||
assert!(
|
||||
[rect.x, rect.y, rect.width, rect.height]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
);
|
||||
assert!(rect.width > 0. && rect.height > 0.);
|
||||
let call_line = scene.lines.iter().find(|l| l.id == steps[0]).unwrap();
|
||||
let reply_line = scene.lines.iter().find(|l| l.id == steps[1]).unwrap();
|
||||
let received = *call_line.points.last().unwrap();
|
||||
let returned = reply_line.points[0];
|
||||
assert_eq!(rect.y, received.y);
|
||||
assert_eq!(rect.y + rect.height, returned.y);
|
||||
assert_eq!(received.x, returned.x);
|
||||
assert!(received.x == rect.x || received.x == rect.x + rect.width);
|
||||
assert!(!scene.boxes.iter().any(|b| b.id == steps[0]));
|
||||
assert!(scene.open_calls.is_empty());
|
||||
let interior = rect.center();
|
||||
assert!(scene.lines.iter().filter(|l| !l.label.is_empty()).all(|l| {
|
||||
l.points.windows(2).all(|p| {
|
||||
crate::diagram::distance_to_segment(interior, p[0], p[1]) > 8. / scene.scale
|
||||
}) && !Rectangle::new(
|
||||
l.label_at,
|
||||
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.),
|
||||
)
|
||||
.contains(interior)
|
||||
}));
|
||||
assert_eq!(scene.hit(interior), Some(steps[0]));
|
||||
let mut reply = State::default();
|
||||
reply
|
||||
.update(result, Event::Start(owner, Tool::Message))
|
||||
|
||||
@@ -10,10 +10,10 @@ fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody)
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
|
||||
&& !steps.contains(&id)
|
||||
{
|
||||
steps.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ pub enum Shelf {
|
||||
pub(super) struct Presentation {
|
||||
pub(super) identity: Option<(Id, Id)>,
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) focused: bool,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) pan: iced::Vector,
|
||||
pub(super) zoom: Option<f32>,
|
||||
@@ -106,35 +105,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
super::super::canvas_workspace::Control::new(
|
||||
button(
|
||||
text(if presentation.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.padding(6)
|
||||
.on_press(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
scope,
|
||||
"Panels",
|
||||
keyboard,
|
||||
Some(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(6);
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||
@@ -148,11 +119,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
keyboard,
|
||||
scale,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
))),
|
||||
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
|
||||
@@ -12,6 +12,15 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let geometry = mounted.geometry(&app);
|
||||
let scope = canvas_workspace::tests::scope(&app);
|
||||
for removed in ["Canvas", "Panels"] {
|
||||
assert!(!geometry.containers.contains_key(&scope.control_id(removed)));
|
||||
}
|
||||
assert!(
|
||||
!canvas_workspace::tests::focused(&mut mounted, &app, "viewport"),
|
||||
"direct editing needs no activation or initial keyboard focus grab"
|
||||
);
|
||||
for size in [Size::new(1100., 700.), Size::new(1440., 900.)] {
|
||||
app.window_size = size;
|
||||
mounted.settle(&mut app).await;
|
||||
@@ -1031,7 +1040,9 @@ fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_f
|
||||
}
|
||||
app.busy = false;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
let epoch = app.canvas_epoch;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.8)));
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
@@ -1332,7 +1343,7 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let snapshot = app.document.snapshot();
|
||||
let source = app.document.bundle().clone();
|
||||
for boundary in ["Fit", "A", "Details", "Panels", "Bounds"] {
|
||||
for boundary in ["Fit", "A", "Details", "ZoomIn", "Bounds"] {
|
||||
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let point = bounds.position() + iced::Vector::new(95., 90.);
|
||||
@@ -1531,10 +1542,7 @@ async fn production_scenario_keyboard_enters_activates_draws_focus_and_returns_t
|
||||
)));
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
|
||||
assert!(focused(&mut mounted, &app, "Fit"));
|
||||
tab_to(&mut mounted, &mut app, "Canvas").await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
tab_to(&mut mounted, &mut app, "viewport").await;
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
@@ -1849,7 +1857,6 @@ async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75)));
|
||||
let canvas = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
@@ -1892,7 +1899,13 @@ async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, Vector::ZERO);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.)));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Action))
|
||||
});
|
||||
assert_eq!(
|
||||
app.behavior.scenario.canvas_tool(b),
|
||||
CanvasTool::Add(Tool::Action)
|
||||
);
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
reference,
|
||||
"Renamed caller use".into(),
|
||||
@@ -1910,7 +1923,6 @@ async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after
|
||||
app.behavior.scenario.canvas_tool(a),
|
||||
CanvasTool::Message(MessageStyle::Timer)
|
||||
);
|
||||
assert!(app.behavior.scenario.focused());
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.shelf,
|
||||
Some(Shelf::Selection)
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Derived call geometry. Correlation and branch ownership come from the model;
|
||||
//! columns separate overlapping drawings and do not assert an execution thread.
|
||||
use super::*;
|
||||
|
||||
const WIDTH: f32 = 12.;
|
||||
const OFFSET: f32 = 6.;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Finish {
|
||||
Reply(Id),
|
||||
Boundary,
|
||||
/// Still open at this scope boundary; the visible span is not an isolated stub.
|
||||
Continuation,
|
||||
Open,
|
||||
/// The receiver was deleted without answering this call.
|
||||
Terminated(Id),
|
||||
/// Lifetime clipping ended this visible piece before its logical terminal.
|
||||
Clipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Segment {
|
||||
pub call: Id,
|
||||
pub receiver: Id,
|
||||
pub scope: Id,
|
||||
pub rect: Rectangle,
|
||||
pub column: usize,
|
||||
pub finish: Finish,
|
||||
}
|
||||
impl Segment {
|
||||
fn bottom(&self) -> f32 {
|
||||
self.rect.y + self.rect.height
|
||||
}
|
||||
fn overlaps(&self, other: &Self) -> bool {
|
||||
self.receiver == other.receiver
|
||||
&& self.scope == other.scope
|
||||
&& self.rect.y < other.bottom()
|
||||
&& other.rect.y < self.bottom()
|
||||
}
|
||||
fn edge(&self, right: bool) -> f32 {
|
||||
self.rect.x + if right { self.rect.width } else { 0. }
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) {
|
||||
// Logical endpoints precede clipping and remain available even when a
|
||||
// lifetime hides a whole execution or terminates it before its Reply.
|
||||
let correlations = logical_correlations(p, &self.rows, &self.lines);
|
||||
let mut calls: Vec<_> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|(&call, &(scope, y))| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
receiver: Some(receiver),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&call).and_then(|e| e.shared())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some((call, *receiver, scope, y, *lanes.get(receiver)?))
|
||||
})
|
||||
.collect();
|
||||
// Rows are laid out in semantic order. UUID order must not choose which
|
||||
// rectangle obscures another (especially after source reidentification).
|
||||
calls.sort_by(|a, b| a.3.total_cmp(&b.3).then_with(|| a.0.cmp(&b.0)));
|
||||
for &(call, receiver, scope, _, x) in &calls {
|
||||
let first = self.activations.len();
|
||||
let first_cue = self.open_calls.len();
|
||||
self.activation_sequence(p, scope, call, receiver, x, None);
|
||||
let column = self.activations[first..]
|
||||
.iter()
|
||||
.flat_map(|segment| {
|
||||
self.activations[..first]
|
||||
.iter()
|
||||
.filter(move |other| segment.overlaps(other))
|
||||
})
|
||||
.map(|other| other.column + 1)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
for segment in &mut self.activations[first..] {
|
||||
segment.column = column;
|
||||
segment.rect.x += column as f32 * OFFSET;
|
||||
}
|
||||
for cue in &mut self.open_calls[first_cue..] {
|
||||
cue.x += column as f32 * OFFSET;
|
||||
}
|
||||
}
|
||||
// Shallow executions paint first; columns cannot collide within a scope.
|
||||
self.activations.sort_by(|a, b| {
|
||||
a.column
|
||||
.cmp(&b.column)
|
||||
.then_with(|| a.rect.y.total_cmp(&b.rect.y))
|
||||
.then_with(|| a.call.cmp(&b.call))
|
||||
});
|
||||
self.anchor_activation_messages(p, lanes, &correlations);
|
||||
}
|
||||
|
||||
fn activation_segment(
|
||||
&mut self,
|
||||
call: Id,
|
||||
receiver: Id,
|
||||
scope: Id,
|
||||
x: f32,
|
||||
span: std::ops::Range<f32>,
|
||||
finish: Finish,
|
||||
) {
|
||||
// Cues describe unanswered logical paths, including wholly invisible
|
||||
// ones. Never derive them only from the surviving paint rectangles.
|
||||
if matches!(
|
||||
finish,
|
||||
Finish::Open | Finish::Continuation | Finish::Terminated(_)
|
||||
) {
|
||||
self.open_calls
|
||||
.push(Point::new(x + WIDTH / 2. + 4., span.end - 7.));
|
||||
}
|
||||
for alive in self
|
||||
.lifecycle
|
||||
.alive_spans(receiver, scope, span.start, span.end)
|
||||
{
|
||||
self.activations.push(Segment {
|
||||
call,
|
||||
receiver,
|
||||
scope,
|
||||
rect: Rectangle::new(
|
||||
Point::new(x - WIDTH / 2., alive.start),
|
||||
Size::new(WIDTH, alive.end - alive.start),
|
||||
),
|
||||
column: 0,
|
||||
finish: if alive.end == span.end {
|
||||
finish
|
||||
} else {
|
||||
Finish::Clipped
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow one exact correlation. A segment belongs to one ordered scope;
|
||||
/// vertically stacked operands must not become a single chronological span.
|
||||
fn activation_sequence(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
call: Id,
|
||||
receiver: Id,
|
||||
x: f32,
|
||||
mut start: Option<f32>,
|
||||
) -> Outcome {
|
||||
let entered_open = start.is_some();
|
||||
let mut split = false;
|
||||
let mut may_be_terminated = false;
|
||||
for id in p.elements[&owner].steps().into_iter().flatten() {
|
||||
let Some(&(_, y)) = self.rows.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if *id == call {
|
||||
// A reflective call is received at the lower end of its loop.
|
||||
start = Some(
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| line.id == call)
|
||||
.and_then(|line| line.points.last())
|
||||
.map_or(y, |point| point.y),
|
||||
);
|
||||
}
|
||||
let Some(top) = start else {
|
||||
continue;
|
||||
};
|
||||
match p.elements[id].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Delete,
|
||||
receiver: Some(deleted),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) if *deleted == receiver => {
|
||||
self.activation_segment(
|
||||
call,
|
||||
receiver,
|
||||
owner,
|
||||
x,
|
||||
top..y,
|
||||
Finish::Terminated(*id),
|
||||
);
|
||||
start = None;
|
||||
may_be_terminated = true;
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
reply_to: Some(correlation),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) if *correlation == call => {
|
||||
// Execution ends when its reply is sent, including a self reply.
|
||||
self.activation_segment(call, receiver, owner, x, top..y, Finish::Reply(*id));
|
||||
start = None;
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { operator, branches },
|
||||
..
|
||||
}) => {
|
||||
split = true;
|
||||
self.activation_segment(call, receiver, owner, x, top..y, Finish::Boundary);
|
||||
let bottom = self
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|b| b.id == *id && !b.title.is_empty())
|
||||
.map(|b| b.rect.y + b.rect.height)
|
||||
.unwrap_or(y);
|
||||
let outcomes: Vec<_> = branches
|
||||
.iter()
|
||||
.filter_map(|branch| {
|
||||
let &(top, _) = self.sequence_bounds.get(branch)?;
|
||||
Some(self.activation_sequence(p, *branch, call, receiver, x, Some(top)))
|
||||
})
|
||||
.collect();
|
||||
let open = match operator {
|
||||
FragmentOperator::Alternative => {
|
||||
outcomes.is_empty() || outcomes.iter().any(|outcome| outcome.open)
|
||||
}
|
||||
FragmentOperator::Parallel => outcomes.iter().all(|outcome| outcome.open),
|
||||
FragmentOperator::Optional | FragmentOperator::Loop => true,
|
||||
};
|
||||
if outcomes.iter().any(|outcome| outcome.may_be_terminated) {
|
||||
// Sibling bars retain their own entry paths, but a
|
||||
// conditional death cannot become a definite parent
|
||||
// execution again after a later Create.
|
||||
self.open_calls
|
||||
.push(Point::new(x + WIDTH / 2. + 4., bottom - 7.));
|
||||
may_be_terminated = true;
|
||||
start = None;
|
||||
} else {
|
||||
start = open.then_some(bottom);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(top) = start {
|
||||
let bottom = if entered_open || split {
|
||||
self.sequence_bounds
|
||||
.get(&owner)
|
||||
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
|
||||
} else {
|
||||
top + 26.
|
||||
};
|
||||
let finish = if entered_open || split {
|
||||
Finish::Continuation
|
||||
} else {
|
||||
Finish::Open
|
||||
};
|
||||
self.activation_segment(call, receiver, owner, x, top..bottom, finish);
|
||||
}
|
||||
Outcome {
|
||||
open: start.is_some(),
|
||||
may_be_terminated,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_activation_messages(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
correlations: &BTreeMap<Id, Correlation>,
|
||||
) {
|
||||
let segments = &self.activations;
|
||||
for line in &mut self.lines {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&line.id).and_then(|e| e.shared())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
message_kind,
|
||||
MessageKind::Call | MessageKind::Reply | MessageKind::Asynchronous
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let (Some(&from), Some(&to), Some(&(scope, _))) = (
|
||||
lanes.get(sender),
|
||||
lanes.get(receiver),
|
||||
self.rows.get(&line.id),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let send_y = line.points[0].y;
|
||||
let receive_y = line.points.last().unwrap().y;
|
||||
let reflective = sender == receiver;
|
||||
let source = if *message_kind == MessageKind::Reply {
|
||||
reply_to.and_then(|call| {
|
||||
segments.iter().find(|a| {
|
||||
a.call == call
|
||||
&& a.scope == scope
|
||||
&& a.finish == Finish::Reply(line.id)
|
||||
&& a.bottom() == send_y
|
||||
})
|
||||
})
|
||||
} else {
|
||||
active_segment(
|
||||
p,
|
||||
segments,
|
||||
correlations,
|
||||
*sender,
|
||||
scope,
|
||||
send_y,
|
||||
Some(line.id),
|
||||
)
|
||||
};
|
||||
let target = if *message_kind == MessageKind::Call {
|
||||
segments
|
||||
.iter()
|
||||
.find(|a| a.call == line.id && a.scope == scope && a.rect.y == receive_y)
|
||||
} else {
|
||||
active_segment(
|
||||
p,
|
||||
segments,
|
||||
correlations,
|
||||
*receiver,
|
||||
scope,
|
||||
receive_y,
|
||||
*reply_to,
|
||||
)
|
||||
};
|
||||
let start = source.map_or(from, |a| a.edge(reflective || to > from));
|
||||
let end = target.map_or(to, |a| a.edge(reflective || from > to));
|
||||
if reflective {
|
||||
let outside = start.max(end) + 60.;
|
||||
line.points = vec![
|
||||
Point::new(start, send_y),
|
||||
Point::new(outside, send_y),
|
||||
Point::new(outside, receive_y),
|
||||
Point::new(end, receive_y),
|
||||
];
|
||||
} else {
|
||||
line.points = vec![Point::new(start, send_y), Point::new(end, receive_y)];
|
||||
}
|
||||
line.label_at.x = start.min(end) + 12.;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Outcome {
|
||||
open: bool,
|
||||
may_be_terminated: bool,
|
||||
}
|
||||
|
||||
fn logical_correlations(
|
||||
p: &Project,
|
||||
rows: &BTreeMap<Id, (Id, f32)>,
|
||||
lines: &[Line],
|
||||
) -> BTreeMap<Id, Correlation> {
|
||||
let mut result = BTreeMap::new();
|
||||
let points: BTreeMap<_, _> = lines.iter().map(|line| (line.id, &line.points)).collect();
|
||||
for (&id, &(_, y)) in rows {
|
||||
if matches!(
|
||||
p.elements.get(&id).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) {
|
||||
result.insert(
|
||||
id,
|
||||
Correlation {
|
||||
start: points.get(&id).and_then(|p| p.last()).map_or(y, |p| p.y),
|
||||
replies: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
for (&id, &(scope, y)) in rows {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
reply_to: Some(call),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&id).and_then(|e| e.shared())
|
||||
&& let Some(correlation) = result.get_mut(call)
|
||||
{
|
||||
let sent = points.get(&id).and_then(|p| p.first()).map_or(y, |p| p.y);
|
||||
correlation.replies.push((scope, sent));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// A caller has no implicit execution just because it sent a message. Only an
|
||||
/// actually drawn, known-duration execution can supply its anchor. Crossed
|
||||
/// intervals provide no unambiguous innermost execution, so fall back to the lane.
|
||||
fn active_segment<'a>(
|
||||
p: &Project,
|
||||
segments: &'a [Segment],
|
||||
correlations: &BTreeMap<Id, Correlation>,
|
||||
receiver: Id,
|
||||
scope: Id,
|
||||
y: f32,
|
||||
exclude: Option<Id>,
|
||||
) -> Option<&'a Segment> {
|
||||
let candidates: Vec<_> = segments
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.receiver == receiver
|
||||
&& a.scope == scope
|
||||
&& Some(a.call) != exclude
|
||||
&& a.rect.y <= y
|
||||
&& y < a.bottom()
|
||||
&& a.finish != Finish::Open
|
||||
})
|
||||
.collect();
|
||||
let inner = candidates.iter().max_by_key(|a| a.column).copied()?;
|
||||
candidates
|
||||
.iter()
|
||||
.all(|a| {
|
||||
a.call == inner.call
|
||||
|| correlations[&a.call].contains(p, scope, &correlations[&inner.call])
|
||||
})
|
||||
.then_some(inner)
|
||||
}
|
||||
|
||||
struct Correlation {
|
||||
start: f32,
|
||||
replies: Vec<(Id, f32)>,
|
||||
}
|
||||
impl Correlation {
|
||||
fn contains(&self, p: &Project, scope: Id, inner: &Self) -> bool {
|
||||
// Segment clipping at a fragment can make crossed calls appear nested.
|
||||
// Compare actual correlated replies on compatible scope paths instead.
|
||||
let relevant = |&(reply_scope, _): &(Id, f32)| {
|
||||
ancestor(p, scope, reply_scope) || ancestor(p, reply_scope, scope)
|
||||
};
|
||||
let mut compared = false;
|
||||
for &(inner_scope, inner_end) in inner.replies.iter().filter(|reply| relevant(reply)) {
|
||||
let mut matched = false;
|
||||
for &(outer_scope, outer_end) in self.replies.iter().filter(|reply| relevant(reply)) {
|
||||
if ancestor(p, inner_scope, outer_scope) || ancestor(p, outer_scope, inner_scope) {
|
||||
// Alternative operands are not chronological. Compare only
|
||||
// returns that can belong to the same enclosing branch path.
|
||||
if outer_end < inner_end {
|
||||
return false;
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false;
|
||||
}
|
||||
compared = true;
|
||||
}
|
||||
self.start <= inner.start && compared
|
||||
}
|
||||
}
|
||||
fn ancestor(p: &Project, expected: Id, mut scope: Id) -> bool {
|
||||
for _ in 0..=p.elements.len() {
|
||||
if scope == expected {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = p.elements.get(&scope).and_then(|e| e.parent) else {
|
||||
return false;
|
||||
};
|
||||
scope = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,842 @@
|
||||
use super::*;
|
||||
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(0x21ca1100000040008000000000000000 + n)
|
||||
}
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
caller: Id,
|
||||
callee: Id,
|
||||
call: Id,
|
||||
inner: Id,
|
||||
reply: Id,
|
||||
inner_reply: Id,
|
||||
}
|
||||
fn seed(p: &mut Project, layer: Layer, n: u128, kind: Kind, parent: Option<Id>) -> Id {
|
||||
let mut e = p
|
||||
.make_element(kind, layer, &format!("Element {n}"), parent)
|
||||
.unwrap();
|
||||
e.id = id(n);
|
||||
e.alias = format!("element_{n}");
|
||||
p.elements.insert(e.id, e);
|
||||
id(n)
|
||||
}
|
||||
fn message(
|
||||
p: &mut Project,
|
||||
layer: Layer,
|
||||
n: u128,
|
||||
parent: Id,
|
||||
endpoints: (Id, Id),
|
||||
reply: Option<Id>,
|
||||
) -> Id {
|
||||
let e = seed(p, layer, n, Kind::Occurrence, Some(parent));
|
||||
*p.elements.get_mut(&e).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: Some(endpoints.0),
|
||||
receiver: Some(endpoints.1),
|
||||
message_kind: if reply.is_some() {
|
||||
MessageKind::Reply
|
||||
} else {
|
||||
MessageKind::Call
|
||||
},
|
||||
reply_to: reply,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
e
|
||||
}
|
||||
fn fixture(layer: Layer, reverse_uuid_order: bool) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let work = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let a = seed(&mut p, layer, 1, work, None);
|
||||
let b = seed(&mut p, layer, 2, work, None);
|
||||
let owner = seed(&mut p, layer, 3, Kind::Scenario, None);
|
||||
if let SharedBody::Scenario { viewpoint, .. } =
|
||||
p.elements.get_mut(&owner).unwrap().shared_mut().unwrap()
|
||||
{
|
||||
*viewpoint = if layer == Layer::Oa {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
} else {
|
||||
ScenarioViewpoint::Functional
|
||||
};
|
||||
}
|
||||
let caller = seed(&mut p, layer, 4, Kind::Lifeline, Some(owner));
|
||||
let callee = seed(&mut p, layer, 5, Kind::Lifeline, Some(owner));
|
||||
for (line, represented) in [(caller, a), (callee, b)] {
|
||||
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let (first, second) = if reverse_uuid_order {
|
||||
(40, 30)
|
||||
} else {
|
||||
(30, 40)
|
||||
};
|
||||
let call = message(&mut p, layer, first, owner, (caller, callee), None);
|
||||
let inner = message(&mut p, layer, second, owner, (caller, callee), None);
|
||||
let reply = message(&mut p, layer, 50, owner, (callee, caller), Some(call));
|
||||
let inner_reply = message(&mut p, layer, 60, owner, (callee, caller), Some(inner));
|
||||
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() =
|
||||
vec![call, inner, inner_reply, reply];
|
||||
Fixture {
|
||||
project: p,
|
||||
owner,
|
||||
caller,
|
||||
callee,
|
||||
call,
|
||||
inner,
|
||||
reply,
|
||||
inner_reply,
|
||||
}
|
||||
}
|
||||
fn scene(f: &Fixture) -> Scene {
|
||||
let errors: Vec<_> = validation::validate(&f.project)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "Actual core fixture: {errors:#?}");
|
||||
let source = language::format(&f.project);
|
||||
let reparsed = language::parse(&source).unwrap();
|
||||
assert_eq!(language::format(&reparsed), source);
|
||||
let result = Scene::sequence(&reparsed, f.owner, f.owner);
|
||||
assert_eq!(
|
||||
language::format(&reparsed),
|
||||
source,
|
||||
"Projection must not write geometry into the model"
|
||||
);
|
||||
result
|
||||
}
|
||||
fn bar(scene: &Scene, call: Id) -> &Segment {
|
||||
scene.activations.iter().find(|a| a.call == call).unwrap()
|
||||
}
|
||||
|
||||
fn long_labels(f: &mut Fixture) {
|
||||
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
|
||||
f.project.elements.get_mut(&occurrence).unwrap().name =
|
||||
"Pressure verification with a descriptive operation label".into();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_shoulders_under_label_backplates_select_their_call() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer, true);
|
||||
long_labels(&mut f);
|
||||
let scene = scene(&f);
|
||||
let outer = bar(&scene, f.call);
|
||||
for occurrence in [f.inner, f.inner_reply, f.reply] {
|
||||
let message = line(&scene, occurrence);
|
||||
let shoulder = Point::new(outer.rect.x + 1., message.label_at.y + 11.);
|
||||
assert!(outer.rect.contains(shoulder));
|
||||
assert!(Rectangle::new(message.label_at, Size::new(225., 22.)).contains(shoulder));
|
||||
assert!(message.label.chars().count() > 32);
|
||||
assert!(message.points.windows(2).all(|p| {
|
||||
crate::diagram::distance_to_segment(shoulder, p[0], p[1]) > 8. / scene.scale
|
||||
}));
|
||||
assert_eq!(scene.hit(shoulder), Some(f.call));
|
||||
assert_eq!(
|
||||
scene.hit(message.label_at + iced::Vector::new(4., 4.)),
|
||||
Some(occurrence),
|
||||
"Label text outside activations remains selectable"
|
||||
);
|
||||
}
|
||||
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
|
||||
let message = line(&scene, occurrence);
|
||||
for endpoint in [message.points[0], *message.points.last().unwrap()] {
|
||||
assert_eq!(scene.hit(endpoint), Some(occurrence));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_message_backplates_do_not_erase_selected_activation_border_pixels() {
|
||||
use iced::advanced::renderer::Headless;
|
||||
use iced_runtime::user_interface::{Cache, UserInterface};
|
||||
|
||||
let mut mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await;
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
long_labels(&mut f);
|
||||
let mut scene = scene(&f);
|
||||
scene.selected = f.call;
|
||||
let border_x = bar(&scene, f.call).rect.x;
|
||||
let label_spans: Vec<_> = [f.inner, f.inner_reply, f.reply]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let message = line(&scene, id);
|
||||
assert!(
|
||||
Rectangle::new(message.label_at, Size::new(225., 22.))
|
||||
.contains(Point::new(border_x, message.label_at.y + 11.))
|
||||
);
|
||||
(message.label_at.y + 3.) as usize..(message.label_at.y + 20.) as usize
|
||||
})
|
||||
.collect();
|
||||
let dimensions = Size::new(800., 700.);
|
||||
let content: Element<'_, Message> = canvas::Canvas::new(scene)
|
||||
.width(dimensions.width)
|
||||
.height(dimensions.height)
|
||||
.into();
|
||||
let mut ui = UserInterface::build(content, dimensions, Cache::new(), &mut mounted.renderer);
|
||||
ui.draw(
|
||||
&mut mounted.renderer,
|
||||
&style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: style::INK,
|
||||
},
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
let pixels = Headless::screenshot(&mut mounted.renderer, Size::new(800, 700), 1., Color::WHITE);
|
||||
let expected = [style::TEAL.r, style::TEAL.g, style::TEAL.b].map(|v| v * 255.);
|
||||
for span in label_spans {
|
||||
for y in span {
|
||||
assert!(
|
||||
((border_x as usize - 2)..=(border_x as usize + 2)).any(|x| {
|
||||
let offset = (y * 800 + x) * 4;
|
||||
pixels[offset..offset + 3]
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(actual, target)| (*actual as f32 - target).abs() < 30.)
|
||||
}),
|
||||
"Selected activation border was erased at y={y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn line(scene: &Scene, occurrence: Id) -> &super::super::Line {
|
||||
scene.lines.iter().find(|a| a.id == occurrence).unwrap()
|
||||
}
|
||||
fn endpoints(p: &mut Project, occurrence: Id, from: Id, to: Id) {
|
||||
if let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
},
|
||||
..
|
||||
} = p
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*sender = Some(from);
|
||||
*receiver = Some(to);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_calls_expose_both_bars_and_end_at_their_exact_replies_in_both_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture(layer, true);
|
||||
if reverse {
|
||||
for occurrence in [f.call, f.inner] {
|
||||
endpoints(&mut f.project, occurrence, f.callee, f.caller);
|
||||
}
|
||||
for occurrence in [f.reply, f.inner_reply] {
|
||||
endpoints(&mut f.project, occurrence, f.caller, f.callee);
|
||||
}
|
||||
}
|
||||
let scene = scene(&f);
|
||||
let outer = bar(&scene, f.call);
|
||||
let inner = bar(&scene, f.inner);
|
||||
assert!(outer.rect.y < inner.rect.y && outer.bottom() > inner.bottom());
|
||||
assert_eq!(outer.column, 0);
|
||||
assert_eq!(inner.column, 1);
|
||||
assert!(outer.rect.x < inner.rect.x && outer.edge(true) < inner.edge(true));
|
||||
for (call, reply) in [(f.call, f.reply), (f.inner, f.inner_reply)] {
|
||||
let execution = bar(&scene, call);
|
||||
let received = *line(&scene, call).points.last().unwrap();
|
||||
let returned = line(&scene, reply).points[0];
|
||||
assert_eq!(execution.rect.y, received.y);
|
||||
assert_eq!(execution.bottom(), returned.y);
|
||||
assert_eq!(received.x, execution.edge(reverse));
|
||||
assert_eq!(returned.x, execution.edge(reverse));
|
||||
assert_eq!(execution.finish, Finish::Reply(reply));
|
||||
}
|
||||
assert!(scene.open_calls.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_uuid_order_does_not_choose_activation_columns_or_message_anchors() {
|
||||
let f = fixture(Layer::Oa, false);
|
||||
let g = fixture(Layer::Oa, true);
|
||||
let a = scene(&f);
|
||||
let b = scene(&g);
|
||||
for (x, y) in [
|
||||
(f.call, g.call),
|
||||
(f.inner, g.inner),
|
||||
(f.reply, g.reply),
|
||||
(f.inner_reply, g.inner_reply),
|
||||
] {
|
||||
assert_eq!(line(&a, x).points, line(&b, y).points);
|
||||
}
|
||||
for (x, y) in [(f.call, g.call), (f.inner, g.inner)] {
|
||||
assert_eq!(bar(&a, x).rect, bar(&b, y).rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_calls_reuse_column_and_crossed_replies_keep_their_correlations() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer, true);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.call, f.reply, f.inner, f.inner_reply];
|
||||
let sequential = scene(&f);
|
||||
assert_eq!(
|
||||
bar(&sequential, f.call).rect.x,
|
||||
bar(&sequential, f.inner).rect.x
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.call, f.inner, f.reply, f.inner_reply];
|
||||
let crossed = scene(&f);
|
||||
assert!(bar(&crossed, f.call).bottom() < bar(&crossed, f.inner).bottom());
|
||||
assert!(bar(&crossed, f.call).rect.x < bar(&crossed, f.inner).rect.x);
|
||||
assert_eq!(
|
||||
line(&crossed, f.reply).points[0].x,
|
||||
bar(&crossed, f.call).edge(false)
|
||||
);
|
||||
assert_eq!(
|
||||
line(&crossed, f.inner_reply).points[0].x,
|
||||
bar(&crossed, f.inner).edge(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_receives_at_lower_loop_end_and_self_reply_returns_to_the_parent() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer, true);
|
||||
endpoints(&mut f.project, f.inner, f.callee, f.callee);
|
||||
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
|
||||
let scene = scene(&f);
|
||||
let parent = bar(&scene, f.call);
|
||||
let child = bar(&scene, f.inner);
|
||||
let call = line(&scene, f.inner);
|
||||
let reply = line(&scene, f.inner_reply);
|
||||
assert_eq!(call.points[0].x, parent.edge(true));
|
||||
assert_eq!(call.points[3], Point::new(child.edge(true), child.rect.y));
|
||||
assert_eq!(child.rect.y, scene.rows[&f.inner].1 + 24.);
|
||||
assert_eq!(
|
||||
reply.points[0],
|
||||
Point::new(child.edge(true), child.bottom())
|
||||
);
|
||||
assert_eq!(reply.points[3].x, parent.edge(true));
|
||||
assert!(call.points[1].x > child.edge(true));
|
||||
assert!(reply.points[1].x > child.edge(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inactive_self_call_has_no_fabricated_parent_and_three_nested_calls_expose_three_columns() {
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
endpoints(&mut f.project, f.inner, f.callee, f.callee);
|
||||
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
|
||||
f.project.elements.remove(&f.call);
|
||||
f.project.elements.remove(&f.reply);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.inner, f.inner_reply];
|
||||
let isolated = scene(&f);
|
||||
assert_eq!(isolated.activations.len(), 1);
|
||||
assert_eq!(bar(&isolated, f.inner).column, 0);
|
||||
assert_eq!(
|
||||
line(&isolated, f.inner).points[0].x,
|
||||
isolated.lanes[&f.callee]
|
||||
);
|
||||
assert_eq!(
|
||||
line(&isolated, f.inner_reply).points[3].x,
|
||||
isolated.lanes[&f.callee]
|
||||
);
|
||||
assert_eq!(
|
||||
bar(&isolated, f.inner).rect.y,
|
||||
line(&isolated, f.inner).points[3].y
|
||||
);
|
||||
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
let third = message(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
20,
|
||||
f.owner,
|
||||
(f.caller, f.callee),
|
||||
None,
|
||||
);
|
||||
let third_reply = message(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
21,
|
||||
f.owner,
|
||||
(f.callee, f.caller),
|
||||
Some(third),
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.call, f.inner, third, third_reply, f.inner_reply, f.reply];
|
||||
let scene = scene(&f);
|
||||
let calls: Vec<_> = scene.activations.iter().map(|a| a.call).collect();
|
||||
assert_eq!(
|
||||
calls,
|
||||
vec![f.call, f.inner, third],
|
||||
"Paint order is shallow to deep"
|
||||
);
|
||||
for (column, call) in [f.call, f.inner, third].into_iter().enumerate() {
|
||||
let execution = bar(&scene, call);
|
||||
assert_eq!(execution.column, column);
|
||||
let y = (bar(&scene, third).rect.y + bar(&scene, third).bottom()) / 2.;
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(execution.rect.x + OFFSET / 2., y)),
|
||||
Some(call)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reentrant_call_uses_the_other_receiver_without_inventing_a_caller_execution() {
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
endpoints(&mut f.project, f.inner, f.callee, f.caller);
|
||||
endpoints(&mut f.project, f.inner_reply, f.caller, f.callee);
|
||||
let scene = scene(&f);
|
||||
assert_eq!(bar(&scene, f.call).column, 0);
|
||||
assert_eq!(bar(&scene, f.inner).column, 0);
|
||||
assert_eq!(bar(&scene, f.inner).receiver, f.caller);
|
||||
assert_eq!(line(&scene, f.call).points[0].x, scene.lanes[&f.caller]);
|
||||
assert_eq!(
|
||||
line(&scene, f.inner).points[0].x,
|
||||
bar(&scene, f.call).edge(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_canvas_pointer_selects_each_exposed_shoulder_under_zoom_and_pan() {
|
||||
let f = fixture(Layer::Oa, true);
|
||||
let mut scene = scene(&f);
|
||||
let context = scenario::direct_tests::context(f.owner);
|
||||
scene.interaction = Some((context, scenario::CanvasTool::Select, true));
|
||||
let y = (bar(&scene, f.inner).rect.y + bar(&scene, f.inner).bottom()) / 2.;
|
||||
let outer = Point::new(bar(&scene, f.call).rect.x + OFFSET / 2., y);
|
||||
let inner = Point::new(bar(&scene, f.inner).edge(true) - OFFSET / 2., y);
|
||||
for scale in [1., 0.25, 0.05] {
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = iced::Vector::new(50., 25.);
|
||||
let bounds = Rectangle::new(Point::new(30., 40.), Size::new(1000., 700.));
|
||||
for (point, call) in [(outer, f.call), (inner, f.inner)] {
|
||||
assert_eq!(scene.hit(point), Some(call));
|
||||
let cursor = bounds.position()
|
||||
+ scene.camera_pan
|
||||
+ iced::Vector::new(point.x * scale, point.y * scale);
|
||||
let mut state = SequenceInput::default();
|
||||
let _ = canvas::Program::update(
|
||||
&scene,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(cursor),
|
||||
);
|
||||
let action = canvas::Program::update(
|
||||
&scene,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(cursor),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(action.into_inner().0,
|
||||
Some(Message::ScenarioCanvas(scenario::CanvasEvent::Select(_, selected))) if selected == call));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_reply_is_a_short_explicit_open_span_and_an_uncorrelated_reply_closes_nothing() {
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
if let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { reply_to, .. },
|
||||
..
|
||||
} = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.reply)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*reply_to = None;
|
||||
}
|
||||
let scene = scene(&f);
|
||||
assert_eq!(bar(&scene, f.call).finish, Finish::Open);
|
||||
assert_eq!(bar(&scene, f.call).rect.height, 26.);
|
||||
assert_eq!(scene.open_calls.len(), 1);
|
||||
assert_eq!(
|
||||
bar(&scene, f.inner).column,
|
||||
0,
|
||||
"Unknown duration does not prove later nesting"
|
||||
);
|
||||
assert_eq!(bar(&scene, f.inner).finish, Finish::Reply(f.inner_reply));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternative_and_parallel_operand_local_calls_do_not_nest_from_screen_order() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
|
||||
let mut f = fixture(layer, true);
|
||||
let fragment = seed(&mut f.project, layer, 70, Kind::Occurrence, Some(f.owner));
|
||||
let branches = [id(71), id(72)];
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&fragment)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: branches.to_vec(),
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
for (index, occurrences) in [vec![f.call, f.reply], vec![f.inner, f.inner_reply]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let branch = seed(
|
||||
&mut f.project,
|
||||
layer,
|
||||
71 + index as u128,
|
||||
Kind::Occurrence,
|
||||
Some(fragment),
|
||||
);
|
||||
for occurrence in &occurrences {
|
||||
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
|
||||
}
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch {
|
||||
guard: format!("condition_{index}"),
|
||||
occurrences,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![fragment];
|
||||
let scene = scene(&f);
|
||||
assert_eq!(bar(&scene, f.call).column, 0);
|
||||
assert_eq!(bar(&scene, f.inner).column, 0);
|
||||
assert_ne!(bar(&scene, f.call).scope, bar(&scene, f.inner).scope);
|
||||
assert!(scene.open_calls.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_execution_anchors_a_self_call_inside_an_operand_before_returning_after_the_fragment() {
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
endpoints(&mut f.project, f.inner, f.callee, f.callee);
|
||||
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
|
||||
let fragment = seed(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
70,
|
||||
Kind::Occurrence,
|
||||
Some(f.owner),
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&fragment)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![id(71), id(72)],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
for (index, occurrences) in [vec![f.inner, f.inner_reply], vec![]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let branch = seed(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
71 + index as u128,
|
||||
Kind::Occurrence,
|
||||
Some(fragment),
|
||||
);
|
||||
for occurrence in &occurrences {
|
||||
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
|
||||
}
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch {
|
||||
guard: format!("condition_{index}"),
|
||||
occurrences,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.call, fragment, f.reply];
|
||||
let scene = scene(&f);
|
||||
let parent = scene
|
||||
.activations
|
||||
.iter()
|
||||
.find(|a| a.call == f.call && a.scope == id(71))
|
||||
.unwrap();
|
||||
assert_eq!(parent.finish, Finish::Continuation);
|
||||
assert_eq!(bar(&scene, f.inner).column, parent.column + 1);
|
||||
assert_eq!(line(&scene, f.inner).points[0].x, parent.edge(true));
|
||||
assert_eq!(line(&scene, f.inner_reply).points[3].x, parent.edge(true));
|
||||
assert!(
|
||||
scene
|
||||
.activations
|
||||
.iter()
|
||||
.any(|a| a.call == f.call && a.finish == Finish::Reply(f.reply))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fragment_cannot_make_crossed_calls_an_unambiguous_caller_execution() {
|
||||
for placement in ["plain", "before_fragment", "in_operand"] {
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
let outgoing = message(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
80,
|
||||
f.owner,
|
||||
(f.callee, f.caller),
|
||||
None,
|
||||
);
|
||||
if let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||
..
|
||||
} = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&outgoing)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*message_kind = MessageKind::Asynchronous;
|
||||
}
|
||||
let mut ordered = vec![f.call, f.inner];
|
||||
if placement != "in_operand" {
|
||||
ordered.push(outgoing);
|
||||
}
|
||||
if placement != "plain" {
|
||||
let fragment = seed(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
70,
|
||||
Kind::Occurrence,
|
||||
Some(f.owner),
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&fragment)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![id(71), id(72)],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
for index in 0..2u128 {
|
||||
let branch = seed(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
71 + index,
|
||||
Kind::Occurrence,
|
||||
Some(fragment),
|
||||
);
|
||||
let occurrences = if index == 0 && placement == "in_operand" {
|
||||
f.project.elements.get_mut(&outgoing).unwrap().parent = Some(branch);
|
||||
vec![outgoing]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch {
|
||||
guard: format!("condition_{index}"),
|
||||
occurrences,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
ordered.push(fragment);
|
||||
}
|
||||
ordered.extend([f.reply, f.inner_reply]);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = ordered;
|
||||
let scene = scene(&f);
|
||||
assert_eq!(
|
||||
line(&scene, outgoing).points[0].x,
|
||||
scene.lanes[&f.callee],
|
||||
"{placement}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_correlations_returning_in_alternative_operands_remain_nested_before_the_fragment() {
|
||||
let mut f = fixture(Layer::Oa, true);
|
||||
let outgoing = message(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
80,
|
||||
f.owner,
|
||||
(f.callee, f.caller),
|
||||
None,
|
||||
);
|
||||
if let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||
..
|
||||
} = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&outgoing)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*message_kind = MessageKind::Asynchronous;
|
||||
}
|
||||
let fragment = seed(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
70,
|
||||
Kind::Occurrence,
|
||||
Some(f.owner),
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&fragment)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![id(71), id(72)],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
for index in 0..2u128 {
|
||||
let branch = seed(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
71 + index,
|
||||
Kind::Occurrence,
|
||||
Some(fragment),
|
||||
);
|
||||
let occurrences = if index == 0 {
|
||||
for reply in [f.inner_reply, f.reply] {
|
||||
f.project.elements.get_mut(&reply).unwrap().parent = Some(branch);
|
||||
}
|
||||
vec![f.inner_reply, f.reply]
|
||||
} else {
|
||||
vec![
|
||||
message(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
90,
|
||||
branch,
|
||||
(f.callee, f.caller),
|
||||
Some(f.inner),
|
||||
),
|
||||
message(
|
||||
&mut f.project,
|
||||
Layer::Oa,
|
||||
91,
|
||||
branch,
|
||||
(f.callee, f.caller),
|
||||
Some(f.call),
|
||||
),
|
||||
]
|
||||
};
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch {
|
||||
guard: format!("condition_{index}"),
|
||||
occurrences,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.call, f.inner, outgoing, fragment];
|
||||
let scene = scene(&f);
|
||||
let inner = scene
|
||||
.activations
|
||||
.iter()
|
||||
.find(|a| a.call == f.inner && a.scope == f.owner)
|
||||
.unwrap();
|
||||
assert_eq!(line(&scene, outgoing).points[0].x, inner.edge(false));
|
||||
}
|
||||
@@ -8,6 +8,21 @@ use iced::{
|
||||
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Test-only geometry input, deliberately upstream of rows, scopes, lifecycle
|
||||
// and activations. This is not an authored placement or drag policy.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_ROW_OFFSETS: std::cell::RefCell<BTreeMap<Id, f32>> = Default::default();
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(in crate::behavior) fn apply_test_row_offset(id: Id, y: &mut f32) {
|
||||
TEST_ROW_OFFSETS.with(|offsets| {
|
||||
if let Some(offset) = offsets.borrow().get(&id) {
|
||||
*y += offset;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct EventMarker {
|
||||
event: Marker,
|
||||
@@ -225,9 +240,9 @@ impl Rendering {
|
||||
..Stroke::default()
|
||||
.with_width(1.)
|
||||
.with_color(if id == selected {
|
||||
Color::from_rgb8(148, 190, 187)
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(214, 222, 222)
|
||||
style::BOUNDARY
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -247,7 +262,7 @@ impl Rendering {
|
||||
if lifetime.lifeline == selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(91, 113, 113)
|
||||
style::BOUNDARY
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,9 @@ use crate::{behavior::Scene, document::Document};
|
||||
use iced::Size;
|
||||
use oa_core::*;
|
||||
|
||||
#[path = "union_tests.rs"]
|
||||
mod union_tests;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
@@ -100,6 +103,21 @@ impl Fixture {
|
||||
},
|
||||
)
|
||||
}
|
||||
fn reply(&mut self, parent: Id, call: Id) -> Id {
|
||||
self.occurrence(
|
||||
parent,
|
||||
"Reply",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(self.exchange),
|
||||
sender: Some(self.receiver),
|
||||
receiver: Some(self.sender),
|
||||
message_kind: MessageKind::Reply,
|
||||
operation: None,
|
||||
reply_to: Some(call),
|
||||
values: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn fragment(&mut self, parent: Id, operator: FragmentOperator) -> Id {
|
||||
self.occurrence(
|
||||
parent,
|
||||
@@ -187,7 +205,7 @@ fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
|
||||
f.project.elements.get_mut(&f.work).unwrap().name = name.clone();
|
||||
let scene = f.scene();
|
||||
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
|
||||
assert_eq!(header.title, format!("Lane · {name}"));
|
||||
assert_eq!(header.title, name);
|
||||
assert_eq!(header.detail, name);
|
||||
assert_eq!(header.rect.size(), Size::new(204., 70.));
|
||||
assert_eq!(scene.hit(header.rect.center()), Some(f.receiver));
|
||||
@@ -488,9 +506,9 @@ fn real_scene_branch_delete_clips_only_its_activation_and_keeps_unanswered_call_
|
||||
let (left_start, left_end) = scene.sequence_bounds[&left];
|
||||
let (right_start, _) = scene.sequence_bounds[&right];
|
||||
let bars: Vec<_> = scene
|
||||
.boxes
|
||||
.activations
|
||||
.iter()
|
||||
.filter(|b| b.id == call && b.rect.width == 8.)
|
||||
.filter(|b| b.call == call && b.receiver == f.receiver)
|
||||
.map(|b| b.rect)
|
||||
.collect();
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario_activation::{Finish, Segment};
|
||||
|
||||
fn line(scene: &Scene, id: Id) -> &crate::behavior::Line {
|
||||
scene.lines.iter().find(|line| line.id == id).unwrap()
|
||||
}
|
||||
fn segments(scene: &Scene, call: Id) -> Vec<&Segment> {
|
||||
scene
|
||||
.activations
|
||||
.iter()
|
||||
.filter(|a| a.call == call)
|
||||
.collect()
|
||||
}
|
||||
fn assert_visible_lifetime(scene: &Scene) {
|
||||
for a in &scene.activations {
|
||||
assert_eq!(
|
||||
scene
|
||||
.lifecycle
|
||||
.alive_spans(a.receiver, a.scope, a.rect.y, a.rect.y + a.rect.height),
|
||||
vec![Span {
|
||||
start: a.rect.y,
|
||||
end: a.rect.y + a.rect.height
|
||||
}]
|
||||
);
|
||||
if let Finish::Reply(reply) = a.finish {
|
||||
assert_eq!(a.rect.y + a.rect.height, line(scene, reply).points[0].y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_delayed_create_nested_calls_replies_delete_share_one_geometry_pipeline() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let birth = f.message(f.owner, MessageKind::Create);
|
||||
let c1 = f.message(f.owner, MessageKind::Call);
|
||||
let c2 = f.message(f.owner, MessageKind::Call);
|
||||
let r2 = f.reply(f.owner, c2);
|
||||
let r1 = f.reply(f.owner, c1);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
let scene = f.scene();
|
||||
assert_eq!(scene.activations.len(), 2);
|
||||
for (call, reply, column) in [(c1, r1, 0), (c2, r2, 1)] {
|
||||
let a = segments(&scene, call)[0];
|
||||
assert_eq!(
|
||||
(a.receiver, a.scope, a.column, a.finish),
|
||||
(f.receiver, f.owner, column, Finish::Reply(reply))
|
||||
);
|
||||
let received = *line(&scene, call).points.last().unwrap();
|
||||
let sent = line(&scene, reply).points[0];
|
||||
assert_eq!((a.rect.y, a.rect.y + a.rect.height), (received.y, sent.y));
|
||||
assert_eq!(received.x, sent.x);
|
||||
assert!(
|
||||
a.rect.y > scene.rows[&birth].1 && a.rect.y + a.rect.height < scene.rows[&death].1
|
||||
);
|
||||
}
|
||||
assert_visible_lifetime(&scene);
|
||||
assert!(scene.open_calls.is_empty());
|
||||
for event in [birth, death] {
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)),
|
||||
Some(event)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_death_and_incomplete_later_reply_do_not_fabricate_an_answered_execution() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let call = f.message(f.owner, MessageKind::Call);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
let reply = f.reply(f.owner, call);
|
||||
if let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
sender, exchange, ..
|
||||
},
|
||||
..
|
||||
} = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&reply)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*sender = None;
|
||||
*exchange = None;
|
||||
}
|
||||
let scene = f.scene();
|
||||
let a = segments(&scene, call)[0];
|
||||
assert_eq!(a.finish, Finish::Terminated(death));
|
||||
assert_eq!(a.rect.y + a.rect.height, scene.rows[&death].1);
|
||||
assert!(
|
||||
scene.boxes.iter().any(|b| b.id == reply),
|
||||
"The incomplete reply remains a repairable card"
|
||||
);
|
||||
assert_eq!(scene.open_calls.len(), 1);
|
||||
assert_visible_lifetime(&scene);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_self_lifecycle_and_self_call_keep_distinct_send_receive_rows() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let action = f.occurrence(
|
||||
f.owner,
|
||||
"Sender preparation",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.sender),
|
||||
},
|
||||
);
|
||||
let birth = f.message(f.owner, MessageKind::Create);
|
||||
let call = f.message(f.owner, MessageKind::Call);
|
||||
let reply = f.reply(f.owner, call);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
for event in [birth, call, reply, death] {
|
||||
if let SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
exchange,
|
||||
..
|
||||
},
|
||||
..
|
||||
} = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&event)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*sender = Some(f.receiver);
|
||||
*receiver = Some(f.receiver);
|
||||
*exchange = None;
|
||||
}
|
||||
}
|
||||
let scene = f.scene();
|
||||
let preparation = scene.boxes.iter().find(|b| b.id == action).unwrap();
|
||||
assert!(line(&scene, birth).label_at.y > preparation.rect.y + preparation.rect.height);
|
||||
for event in [birth, death] {
|
||||
let message = line(&scene, event);
|
||||
assert_eq!(message.points[0].y, scene.rows[&event].1 - 24.);
|
||||
assert_eq!(message.points.last().unwrap().y, scene.rows[&event].1);
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)),
|
||||
Some(event)
|
||||
);
|
||||
}
|
||||
let activation = segments(&scene, call)[0];
|
||||
assert_eq!(activation.rect.y, scene.rows[&call].1 + 24.);
|
||||
assert_eq!(
|
||||
activation.rect.y,
|
||||
line(&scene, call).points.last().unwrap().y
|
||||
);
|
||||
assert_eq!(
|
||||
activation.rect.y + activation.rect.height,
|
||||
line(&scene, reply).points[0].y
|
||||
);
|
||||
assert_eq!(activation.finish, Finish::Reply(reply));
|
||||
assert_visible_lifetime(&scene);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_unresolved_operand_keeps_unanswered_cue_without_any_visible_segment() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
|
||||
let left = f.branch(fragment);
|
||||
f.message(left, MessageKind::Create);
|
||||
let right = f.branch(fragment);
|
||||
let call = f.message(right, MessageKind::Call);
|
||||
let scene = f.scene();
|
||||
assert!(segments(&scene, call).is_empty());
|
||||
assert!(
|
||||
intervals(&scene, f.receiver, right)
|
||||
.iter()
|
||||
.all(|i| i.2 == Life::Unresolved)
|
||||
);
|
||||
assert_eq!(scene.open_calls.len(), 1);
|
||||
assert!(scene.open_calls[0].y > scene.rows[&call].1);
|
||||
assert_eq!(
|
||||
line(&scene, call).points.last().unwrap().x,
|
||||
scene.lanes[&f.receiver]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_calls_without_sender_polylines_still_have_logical_correlation_metadata() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let c1 = f.message(f.owner, MessageKind::Call);
|
||||
let c2 = f.message(f.owner, MessageKind::Call);
|
||||
for call in [c1, c2] {
|
||||
if let SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender, exchange, ..
|
||||
},
|
||||
..
|
||||
} = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&call)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
{
|
||||
*sender = None;
|
||||
*exchange = None;
|
||||
}
|
||||
}
|
||||
let outgoing = f.occurrence(
|
||||
f.owner,
|
||||
"Outgoing",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(f.receiver),
|
||||
receiver: Some(f.sender),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.reply(f.owner, c2);
|
||||
f.reply(f.owner, c1);
|
||||
let scene = f.scene();
|
||||
assert!(scene.boxes.iter().any(|b| b.id == c1));
|
||||
assert!(scene.boxes.iter().any(|b| b.id == c2));
|
||||
let inner = segments(&scene, c2)[0];
|
||||
let expected = if scene.lanes[&f.sender] > scene.lanes[&f.receiver] {
|
||||
inner.rect.x + inner.rect.width
|
||||
} else {
|
||||
inner.rect.x
|
||||
};
|
||||
assert_eq!(line(&scene, outgoing).points[0].x, expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn lifetime_rejected(f: &Fixture) {
|
||||
assert!(validate_candidate(&f.project).is_err());
|
||||
assert!(
|
||||
validation::validate(&f.project)
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error
|
||||
&& issue.code == "lifeline_lifetime")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_prebirth_call_and_postdeath_reply_remain_rejected_with_defensive_scene_clipping() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for later_birth in [false, true] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let call = f.message(f.owner, MessageKind::Call);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
if later_birth {
|
||||
f.message(f.owner, MessageKind::Create);
|
||||
}
|
||||
let reply = f.reply(f.owner, call);
|
||||
lifetime_rejected(&f);
|
||||
// Deliberately bypass Document only for defensive renderer coverage
|
||||
// of rejected data. This is not an admitted editing workflow.
|
||||
let scene = Scene::sequence(&f.project, f.owner, f.owner);
|
||||
assert!(
|
||||
scene
|
||||
.activations
|
||||
.iter()
|
||||
.all(|a| a.rect.y + a.rect.height <= scene.rows[&death].1)
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.activations
|
||||
.iter()
|
||||
.all(|a| a.finish != Finish::Reply(reply))
|
||||
);
|
||||
assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]);
|
||||
assert!(!scene.open_calls.is_empty());
|
||||
assert_visible_lifetime(&scene);
|
||||
}
|
||||
let mut f = Fixture::new(layer);
|
||||
let call = f.message(f.owner, MessageKind::Call);
|
||||
let birth = f.message(f.owner, MessageKind::Create);
|
||||
let reply = f.reply(f.owner, call);
|
||||
lifetime_rejected(&f);
|
||||
let scene = Scene::sequence(&f.project, f.owner, f.owner);
|
||||
assert_eq!(segments(&scene, call)[0].rect.y, scene.rows[&birth].1);
|
||||
assert_eq!(segments(&scene, call)[0].finish, Finish::Reply(reply));
|
||||
assert_eq!(
|
||||
line(&scene, call).points.last().unwrap().x,
|
||||
scene.lanes[&f.receiver]
|
||||
);
|
||||
assert_visible_lifetime(&scene);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_death_then_recreate_never_revives_call_but_keeps_open_sibling() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
f.message(f.owner, MessageKind::Create);
|
||||
let call = f.message(f.owner, MessageKind::Call);
|
||||
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
|
||||
let left = f.branch(fragment);
|
||||
let death = f.message(left, MessageKind::Delete);
|
||||
let right = f.branch(fragment);
|
||||
let rebirth = f.message(f.owner, MessageKind::Create);
|
||||
let reply = f.reply(f.owner, call);
|
||||
lifetime_rejected(&f);
|
||||
let scene = Scene::sequence(&f.project, f.owner, f.owner);
|
||||
let bars = segments(&scene, call);
|
||||
assert!(
|
||||
bars.iter()
|
||||
.any(|a| a.scope == left && a.finish == Finish::Terminated(death))
|
||||
);
|
||||
assert!(
|
||||
bars.iter().any(|a| a.scope == right
|
||||
&& a.rect.height == 26.
|
||||
&& a.finish == Finish::Continuation)
|
||||
);
|
||||
assert!(
|
||||
bars.iter()
|
||||
.all(|a| a.rect.y + a.rect.height < scene.rows[&rebirth].1)
|
||||
);
|
||||
assert!(!bars.iter().any(|a| a.finish == Finish::Reply(reply)));
|
||||
assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]);
|
||||
assert_eq!(
|
||||
scene.open_calls.len(),
|
||||
3,
|
||||
"Terminated, open sibling and uncertain parent cues survive"
|
||||
);
|
||||
assert_visible_lifetime(&scene);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_operand_preserves_original_nested_or_crossed_correlation_distinction() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for nested in [false, true] {
|
||||
let mut f = Fixture::new(layer);
|
||||
f.message(f.owner, MessageKind::Create);
|
||||
let c1 = f.message(f.owner, MessageKind::Call);
|
||||
let c2 = f.message(f.owner, MessageKind::Call);
|
||||
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
|
||||
let left = f.branch(fragment);
|
||||
f.message(left, MessageKind::Delete);
|
||||
let right = f.branch(fragment);
|
||||
let outgoing = f.occurrence(
|
||||
right,
|
||||
"Outgoing",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(f.receiver),
|
||||
receiver: Some(f.sender),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
let r1 = f.reply(f.owner, if nested { c2 } else { c1 });
|
||||
let r2 = f.reply(f.owner, if nested { c1 } else { c2 });
|
||||
lifetime_rejected(&f);
|
||||
let scene = Scene::sequence(&f.project, f.owner, f.owner);
|
||||
let send = line(&scene, outgoing).points[0];
|
||||
let expected = if nested {
|
||||
let inner = segments(&scene, c2)
|
||||
.into_iter()
|
||||
.find(|a| a.scope == right)
|
||||
.unwrap();
|
||||
if scene.lanes[&f.sender] > scene.lanes[&f.receiver] {
|
||||
inner.rect.x + inner.rect.width
|
||||
} else {
|
||||
inner.rect.x
|
||||
}
|
||||
} else {
|
||||
scene.lanes[&f.receiver]
|
||||
};
|
||||
assert_eq!(send.x, expected);
|
||||
for call in [c1, c2] {
|
||||
assert!(segments(&scene, call).iter().any(|a| a.scope == right
|
||||
&& a.rect.y <= send.y
|
||||
&& send.y < a.rect.y + a.rect.height));
|
||||
}
|
||||
assert!(scene.rows[&r1].1 < scene.rows[&r2].1);
|
||||
assert_visible_lifetime(&scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_offset<T>(id: Id, offset: f32, f: impl FnOnce() -> T) -> T {
|
||||
struct Restore(BTreeMap<Id, f32>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
TEST_ROW_OFFSETS.with(|current| *current.borrow_mut() = std::mem::take(&mut self.0));
|
||||
}
|
||||
}
|
||||
let _restore =
|
||||
Restore(TEST_ROW_OFFSETS.with(|current| current.replace(BTreeMap::from([(id, offset)]))));
|
||||
f()
|
||||
}
|
||||
fn snapshot(scene: &Scene) -> String {
|
||||
format!(
|
||||
"{:?}",
|
||||
(
|
||||
&scene.lifecycle,
|
||||
&scene.activations,
|
||||
&scene.open_calls,
|
||||
&scene.rows,
|
||||
&scene.sequence_bounds,
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.id, &l.points, &l.label, l.label_at, l.dashed))
|
||||
.collect::<Vec<_>>(),
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|b| (b.id, b.rect, &b.title, &b.detail, b.marker))
|
||||
.collect::<Vec<_>>(),
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| (s.slot, s.y, &s.label))
|
||||
.collect::<Vec<_>>(),
|
||||
scene.size,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without_authoring() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let birth = f.message(f.owner, MessageKind::Create);
|
||||
let c1 = f.message(f.owner, MessageKind::Call);
|
||||
let c2 = f.message(f.owner, MessageKind::Call);
|
||||
f.reply(f.owner, c2);
|
||||
f.reply(f.owner, c1);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
let document = f.document();
|
||||
let before = document.snapshot();
|
||||
let baseline = Scene::sequence(document.project(), f.owner, c1);
|
||||
let moved = with_offset(birth, 60., || {
|
||||
Scene::sequence(document.project(), f.owner, c1)
|
||||
});
|
||||
assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.);
|
||||
assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.);
|
||||
for (a, b) in baseline.activations.iter().zip(&moved.activations) {
|
||||
assert_eq!(
|
||||
(a.call, a.receiver, a.scope, a.column, a.finish),
|
||||
(b.call, b.receiver, b.scope, b.column, b.finish)
|
||||
);
|
||||
assert_eq!(
|
||||
b.rect,
|
||||
Rectangle::new(
|
||||
a.rect.position() + iced::Vector::new(0., 60.),
|
||||
a.rect.size()
|
||||
)
|
||||
);
|
||||
}
|
||||
assert_eq!(baseline.activations.len(), moved.activations.len());
|
||||
assert_visible_lifetime(&moved);
|
||||
let reparsed = language::parse(&language::format(document.project())).unwrap();
|
||||
assert_eq!(reparsed, *document.project());
|
||||
assert_eq!(
|
||||
snapshot(&moved),
|
||||
with_offset(birth, 60., || snapshot(&Scene::sequence(
|
||||
&reparsed, f.owner, c1
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&baseline),
|
||||
snapshot(&Scene::sequence(document.project(), f.owner, c1))
|
||||
);
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert_eq!(document.revision(), 0);
|
||||
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
|
||||
}
|
||||
}
|
||||
@@ -325,73 +325,50 @@ impl State {
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.editing.as_ref()?;
|
||||
let event = move |edit| Event::Edit(d.token, edit);
|
||||
let kinds = d.scope.kinds(d.layer);
|
||||
let determined = kinds.len() == 1 && d.kind == kinds.first().copied();
|
||||
let mut fields = column![
|
||||
text(if determined {
|
||||
format!("New {} · {}", kinds[0], d.layer)
|
||||
} else {
|
||||
format!("New source · {}", d.layer)
|
||||
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(14),
|
||||
.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)))
|
||||
.size(14)
|
||||
.padding(6),
|
||||
]
|
||||
.spacing(4);
|
||||
if !determined {
|
||||
let mut choices = row![].spacing(6);
|
||||
for kind in kinds {
|
||||
choices = choices.push(
|
||||
container(
|
||||
button(text(kind.to_string()).size(14))
|
||||
.padding(4)
|
||||
.on_press(event(Edit::Kind(kind)))
|
||||
.style(if d.kind == Some(kind) {
|
||||
button::primary
|
||||
} else {
|
||||
button::secondary
|
||||
}),
|
||||
)
|
||||
.id(format!("behavior-source-kind-{kind:?}")),
|
||||
);
|
||||
}
|
||||
fields = fields.push(choices.wrap());
|
||||
}
|
||||
.padding(8),
|
||||
);
|
||||
fields = fields.push(
|
||||
container(
|
||||
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),
|
||||
)
|
||||
.id("behavior-source-parent"),
|
||||
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),
|
||||
);
|
||||
fields = fields
|
||||
.push(
|
||||
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),
|
||||
)
|
||||
.push(
|
||||
text("The parent form is retained. This source is created only with its consumer.")
|
||||
.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))),
|
||||
@@ -509,526 +486,3 @@ impl Draft {
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod layout_tests {
|
||||
use super::*;
|
||||
use iced::advanced::{Layout, Shell, graphics, layout, renderer, text as shaped, widget};
|
||||
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
|
||||
// Use Iced's real paragraph shaping/measurement without a window or GPU.
|
||||
// The unit renderer's zero-size paragraphs cannot detect this regression.
|
||||
struct FontLayout;
|
||||
impl renderer::Renderer for FontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(
|
||||
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut (), handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for FontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = graphics::text::Paragraph;
|
||||
type Editor = ();
|
||||
const ICON_FONT: Font = Font::DEFAULT;
|
||||
const CHECKMARK_ICON: char = '0';
|
||||
const ARROW_DOWN_ICON: char = '0';
|
||||
const SCROLL_UP_ICON: char = '0';
|
||||
const SCROLL_DOWN_ICON: char = '0';
|
||||
const SCROLL_LEFT_ICON: char = '0';
|
||||
const SCROLL_RIGHT_ICON: char = '0';
|
||||
const ICED_LOGO: char = '0';
|
||||
fn default_font(&self) -> Font {
|
||||
Font::DEFAULT
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
Pixels(16.)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
fields: Option<Rectangle>,
|
||||
content: Option<Rectangle>,
|
||||
name: Option<Rectangle>,
|
||||
parent: Option<Rectangle>,
|
||||
actions: Option<Rectangle>,
|
||||
kinds: BTreeMap<String, Rectangle>,
|
||||
text: Vec<(String, Rectangle)>,
|
||||
focus_name: bool,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("behavior-source-actions")) {
|
||||
self.actions = Some(bounds);
|
||||
}
|
||||
if id == Some(&widget::Id::new("behavior-source-parent")) {
|
||||
self.parent = Some(bounds);
|
||||
}
|
||||
for kind in [
|
||||
Kind::Entity,
|
||||
Kind::Actor,
|
||||
Kind::Role,
|
||||
Kind::System,
|
||||
Kind::SystemActor,
|
||||
Kind::Activity,
|
||||
Kind::Function,
|
||||
] {
|
||||
if id == Some(&widget::Id::from(format!("behavior-source-kind-{kind:?}"))) {
|
||||
self.kinds.insert(kind.to_string(), bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&widget::Id::new("behavior-source-fields")) {
|
||||
assert_eq!(
|
||||
offset,
|
||||
Vector::ZERO,
|
||||
"Initial fields must be visible without scrolling"
|
||||
);
|
||||
self.fields = Some(bounds);
|
||||
self.content = Some(content);
|
||||
}
|
||||
}
|
||||
fn text_input(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: &mut dyn widget::operation::TextInput,
|
||||
) {
|
||||
if id == Some(&widget::Id::new(NAME_INPUT)) {
|
||||
self.name = Some(bounds);
|
||||
}
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
if self.focus_name && id == Some(&widget::Id::new(NAME_INPUT)) {
|
||||
state.focus();
|
||||
}
|
||||
}
|
||||
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
||||
self.text.push((text.into(), bounds));
|
||||
}
|
||||
}
|
||||
fn contains(outer: Rectangle, inner: Rectangle) -> bool {
|
||||
inner.width > 0.
|
||||
&& inner.height > 0.
|
||||
&& inner.x >= outer.x - 0.1
|
||||
&& inner.y >= outer.y - 0.1
|
||||
&& inner.x + inner.width <= outer.x + outer.width + 0.1
|
||||
&& inner.y + inner.height <= outer.y + outer.height + 0.1
|
||||
}
|
||||
fn host<'a>(
|
||||
state: &'a State,
|
||||
p: &'a Project,
|
||||
caption: &str,
|
||||
) -> UiElement<'a, Event, iced::Theme, FontLayout> {
|
||||
iced::widget::column![text(caption.to_owned()).size(12), state.view(p).unwrap()]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
fn geometry(
|
||||
view: &mut UiElement<'_, Event, iced::Theme, FontLayout>,
|
||||
tree: &mut widget::Tree,
|
||||
size: Size,
|
||||
) -> (layout::Node, Geometry) {
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &FontLayout, &layout::Limits::new(Size::ZERO, size));
|
||||
let mut geometry = Geometry::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &FontLayout, &mut geometry);
|
||||
(node, geometry)
|
||||
}
|
||||
fn fixture(layer: Layer, viewpoint: Option<ScenarioViewpoint>, work: bool) -> (Project, State) {
|
||||
let mut p = Project::empty();
|
||||
let scope = if work {
|
||||
let owner = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Consumer",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
Scope::Work { owner }
|
||||
} else if let Some(viewpoint) = viewpoint {
|
||||
let owner = p
|
||||
.add_element(
|
||||
Kind::Scenario,
|
||||
layer,
|
||||
"Incident coordination with a long contextual heading 雨 Ω",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Scenario { viewpoint: v, .. }) =
|
||||
p.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
*v = viewpoint;
|
||||
}
|
||||
Scope::Lifeline { owner, viewpoint }
|
||||
} else {
|
||||
Scope::Machine { layer }
|
||||
};
|
||||
let mut state = State::default();
|
||||
let invocation = Id::new_v4();
|
||||
state
|
||||
.update(&p, scope, invocation, Event::Open(invocation))
|
||||
.unwrap();
|
||||
(p, state)
|
||||
}
|
||||
#[test]
|
||||
fn contextual_source_layout_initial_essentials_fit_short_composed_host_with_real_paragraphs() {
|
||||
for (layer, viewpoint, work) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
Some(ScenarioViewpoint::OperationalActivity),
|
||||
false,
|
||||
),
|
||||
(Layer::Sa, Some(ScenarioViewpoint::Functional), false),
|
||||
(
|
||||
Layer::Oa,
|
||||
Some(ScenarioViewpoint::ParticipantInteraction),
|
||||
false,
|
||||
),
|
||||
(Layer::Sa, Some(ScenarioViewpoint::BoundaryInterface), false),
|
||||
(Layer::Oa, None, false),
|
||||
(Layer::Sa, None, false),
|
||||
(Layer::Oa, None, true),
|
||||
(Layer::Sa, None, true),
|
||||
] {
|
||||
let (p, state) = fixture(layer, viewpoint, work);
|
||||
let raw = serde_json::to_value(&state).unwrap();
|
||||
for width in [340., 500.] {
|
||||
// The retained caption + 6px gap leaves approximately the observed 194px card.
|
||||
let size = Size::new(width, 215.);
|
||||
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (_, g) = geometry(&mut view, &mut tree, size);
|
||||
let fields = g.fields.unwrap();
|
||||
assert!(
|
||||
(125. ..=132.).contains(&fields.height),
|
||||
"Measured field viewport: {fields:?}"
|
||||
);
|
||||
assert!(
|
||||
contains(fields, g.name.unwrap()),
|
||||
"Name clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
|
||||
);
|
||||
assert!(
|
||||
contains(fields, g.parent.unwrap()),
|
||||
"Parent clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
|
||||
);
|
||||
assert!(contains(
|
||||
Rectangle::new(Point::ORIGIN, size),
|
||||
g.actions.unwrap()
|
||||
));
|
||||
assert!(fields.y + fields.height <= g.actions.unwrap().y);
|
||||
let expected = state.editing.as_ref().unwrap().scope.kinds(layer);
|
||||
assert_eq!(
|
||||
g.kinds.len(),
|
||||
if expected.len() == 1 {
|
||||
0
|
||||
} else {
|
||||
expected.len()
|
||||
}
|
||||
);
|
||||
for bounds in g.kinds.values() {
|
||||
assert!(
|
||||
contains(fields, *bounds),
|
||||
"Kind clipped: {bounds:?} in {fields:?}"
|
||||
);
|
||||
}
|
||||
let parent_text = g
|
||||
.text
|
||||
.iter()
|
||||
.find(|(s, _)| s.starts_with("Semantic parent:"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parent_text.0,
|
||||
format!("Semantic parent: {layer} layer root")
|
||||
);
|
||||
assert!(
|
||||
parent_text.1.height > 0.,
|
||||
"Paragraph measurement must not be null"
|
||||
);
|
||||
let explanation = g
|
||||
.text
|
||||
.iter()
|
||||
.find(|(s, _)| s.starts_with("The parent form"))
|
||||
.unwrap();
|
||||
assert!(explanation.1.y >= g.parent.unwrap().y + g.parent.unwrap().height);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_value(&state).unwrap(),
|
||||
raw,
|
||||
"Layout must not choose a kind or mutate the draft"
|
||||
);
|
||||
if state.editing.as_ref().unwrap().scope.kinds(layer).len() == 1 {
|
||||
let mut incomplete = state.clone();
|
||||
incomplete.editing.as_mut().unwrap().kind = None;
|
||||
let mut view = host(&incomplete, &p, "Consumer name: Assessment lifeline");
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
|
||||
assert_eq!(
|
||||
g.kinds.len(),
|
||||
1,
|
||||
"An incomplete recovery still needs an explicit repair choice"
|
||||
);
|
||||
assert!(contains(
|
||||
g.fields.unwrap(),
|
||||
*g.kinds.values().next().unwrap()
|
||||
));
|
||||
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
|
||||
assert_eq!(incomplete.editing.as_ref().unwrap().kind, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_source_layout_long_parent_wraps_after_visible_name_without_losing_raw_data() {
|
||||
let (mut p, mut state) = fixture(Layer::Oa, None, true);
|
||||
let parent = p
|
||||
.add_element(
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Northern operations coordination — exact parent 雨",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let d = state.editing.as_ref().unwrap();
|
||||
let token = d.token;
|
||||
let scope = d.scope;
|
||||
let invocation = Id::new_v4();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
scope,
|
||||
invocation,
|
||||
Event::Edit(token, Edit::Parent(Some(parent))),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
scope,
|
||||
invocation,
|
||||
Event::Edit(token, Edit::Name(" Distinct source 雨 ".into())),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = serde_json::to_value(&state).unwrap();
|
||||
for width in [340., 500.] {
|
||||
let size = Size::new(width, 235.);
|
||||
let mut view = host(
|
||||
&state,
|
||||
&p,
|
||||
"Consumer name: Coordination with a long independent label 雨 Ω",
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (_, g) = geometry(&mut view, &mut tree, size);
|
||||
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
|
||||
assert!(contains(g.fields.unwrap(), g.parent.unwrap()));
|
||||
assert!(contains(
|
||||
Rectangle::new(Point::ORIGIN, size),
|
||||
g.actions.unwrap()
|
||||
));
|
||||
let summary = g
|
||||
.text
|
||||
.iter()
|
||||
.find(|(s, _)| s.starts_with("Semantic parent:"))
|
||||
.unwrap();
|
||||
assert!(summary.0.contains(&p.elements[&parent].name));
|
||||
assert!(summary.0.contains(&p.elements[&parent].alias));
|
||||
assert!(
|
||||
summary.1.height > 15.,
|
||||
"Long parent should exercise real wrapping"
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&state).unwrap(), raw);
|
||||
assert_eq!(
|
||||
state.editing.as_ref().unwrap().name,
|
||||
" Distinct source 雨 "
|
||||
);
|
||||
|
||||
let (mut p, mut state) = fixture(
|
||||
Layer::Oa,
|
||||
Some(ScenarioViewpoint::ParticipantInteraction),
|
||||
false,
|
||||
);
|
||||
let parent = p
|
||||
.add_element(
|
||||
Kind::Entity,
|
||||
Layer::Oa,
|
||||
// Eight repetitions need not exceed a whole viewport with the
|
||||
// platform's measured font. Exercise substantial real overflow.
|
||||
&"Northern operational responsibility 雨 ".repeat(32),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let d = state.editing.as_ref().unwrap();
|
||||
let token = d.token;
|
||||
let scope = d.scope;
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
scope,
|
||||
Id::new_v4(),
|
||||
Event::Edit(token, Edit::Kind(Kind::Actor)),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
scope,
|
||||
Id::new_v4(),
|
||||
Event::Edit(token, Edit::Parent(Some(parent))),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = serde_json::to_value(&state).unwrap();
|
||||
let recovered: State = serde_json::from_value(raw.clone()).unwrap();
|
||||
let mut view = host(&recovered, &p, "Consumer name: Assessment lifeline");
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
|
||||
let fields = g.fields.unwrap();
|
||||
assert!(contains(fields, g.name.unwrap()));
|
||||
assert_eq!(g.kinds.len(), 3);
|
||||
for bounds in g.kinds.values() {
|
||||
assert!(
|
||||
contains(fields, *bounds),
|
||||
"Long parent must not hide a semantic choice"
|
||||
);
|
||||
assert!(bounds.y + bounds.height <= g.parent.unwrap().y);
|
||||
}
|
||||
// Arbitrary authored detail may scroll, but its label and first line are
|
||||
// visible after the required kind controls, never in front of them.
|
||||
let parent_bounds = g.parent.unwrap();
|
||||
assert!(parent_bounds.y + 16. <= fields.y + fields.height);
|
||||
assert!(
|
||||
parent_bounds.height > fields.height,
|
||||
"Exercise genuinely overflowing parent text: {parent_bounds:?} in {fields:?}"
|
||||
);
|
||||
let content = g.content.unwrap();
|
||||
assert!(
|
||||
content.height > fields.height && contains(content, parent_bounds),
|
||||
"Full parent must remain in scrollable content: {parent_bounds:?}, {content:?}, {fields:?}"
|
||||
);
|
||||
let summary = g
|
||||
.text
|
||||
.iter()
|
||||
.find(|(s, _)| s.starts_with("Semantic parent:"))
|
||||
.unwrap();
|
||||
assert!(summary.0.contains(&p.elements[&parent].name));
|
||||
assert!(summary.0.contains(&p.elements[&parent].alias));
|
||||
assert!(contains(
|
||||
Rectangle::new(Point::ORIGIN, Size::new(340., 215.)),
|
||||
g.actions.unwrap()
|
||||
));
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap(), raw);
|
||||
}
|
||||
#[test]
|
||||
fn contextual_source_layout_visible_kind_click_keeps_choice_explicit_and_name_input_works() {
|
||||
let (p, mut state) = fixture(
|
||||
Layer::Oa,
|
||||
Some(ScenarioViewpoint::ParticipantInteraction),
|
||||
false,
|
||||
);
|
||||
let original = p.clone();
|
||||
let token = state.editing.as_ref().unwrap().token;
|
||||
let scope = state.editing.as_ref().unwrap().scope;
|
||||
assert_eq!(state.editing.as_ref().unwrap().kind, None);
|
||||
let size = Size::new(340., 215.);
|
||||
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, g) = geometry(&mut view, &mut tree, size);
|
||||
let mut messages = Vec::new();
|
||||
for kind in [Kind::Entity, Kind::Actor, Kind::Role] {
|
||||
let bounds = g.kinds[&kind.to_string()];
|
||||
assert!(contains(g.fields.unwrap(), bounds));
|
||||
for action in [
|
||||
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left),
|
||||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&iced::Event::Mouse(action),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Available(bounds.center()),
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::new(Point::ORIGIN, size),
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
matches!(messages.pop(), Some(Event::Edit(id, Edit::Kind(k))) if id == token && k == kind)
|
||||
);
|
||||
}
|
||||
let mut focus = Geometry {
|
||||
focus_name: true,
|
||||
..Geometry::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut focus);
|
||||
let event = iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Character("Ω".into()),
|
||||
modified_key: iced::keyboard::Key::Character("Ω".into()),
|
||||
physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::KeyA),
|
||||
location: iced::keyboard::Location::Standard,
|
||||
modifiers: iced::keyboard::Modifiers::empty(),
|
||||
text: Some("Ω".into()),
|
||||
repeat: false,
|
||||
});
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::new(Point::ORIGIN, size),
|
||||
);
|
||||
let edit = messages.pop().unwrap();
|
||||
assert!(matches!(&edit, Event::Edit(id, Edit::Name(name)) if *id == token && name == "Ω"));
|
||||
drop(view);
|
||||
state.update(&p, scope, Id::new_v4(), edit).unwrap();
|
||||
assert_eq!(
|
||||
state.editing.as_ref().unwrap().kind,
|
||||
None,
|
||||
"Rendering/click event production must not silently adopt a kind"
|
||||
);
|
||||
assert_eq!(state.editing.as_ref().unwrap().name, "Ω");
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ pub struct State {
|
||||
context: Option<Context>,
|
||||
tool: Option<Tool>,
|
||||
pointer: Option<Point>,
|
||||
focused: bool,
|
||||
press: Option<Press>,
|
||||
source: Option<Id>,
|
||||
preview: Option<(Id, Position)>,
|
||||
@@ -405,6 +404,48 @@ fn state_action_rows<'a>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Action and internal-transition rows are selectable relations, not edges or
|
||||
/// placements. Match the exact rows rendered in this dedicated machine view.
|
||||
pub(crate) fn visible_row_relation(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &ProjectedView,
|
||||
id: Id,
|
||||
) -> bool {
|
||||
let Some(machine) = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.and_then(|d| d.projection.state_machine())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(relation) = project.relations.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
let source = match relation.body {
|
||||
RelationBody::StateAction { source, .. } => source,
|
||||
RelationBody::StateTransition {
|
||||
source,
|
||||
target,
|
||||
transition_kind: TransitionKind::Internal,
|
||||
..
|
||||
} if projection::state_machine_contains(project, machine, target) => source,
|
||||
_ => return false,
|
||||
};
|
||||
if !projection::state_machine_contains(project, machine, source) {
|
||||
return false;
|
||||
}
|
||||
view.nodes
|
||||
.iter()
|
||||
.filter(|node| node.element == source)
|
||||
.any(|node| {
|
||||
state_action_rows(project, node)
|
||||
.into_iter()
|
||||
.chain(internal_rows(project, node))
|
||||
.any(|(row, _)| row.id == id)
|
||||
})
|
||||
}
|
||||
|
||||
fn state_action_label(project: &Project, relation: &Relation) -> String {
|
||||
let RelationBody::StateAction { target, phase, .. } = relation.body else {
|
||||
return String::new();
|
||||
@@ -938,7 +979,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.reset();
|
||||
state.focused = false;
|
||||
return None;
|
||||
}
|
||||
_ => cursor,
|
||||
@@ -946,14 +986,43 @@ 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
|
||||
&& state.focused
|
||||
{
|
||||
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||
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)
|
||||
&& !visible_row_relation(self.project, self.diagram, &scene.view, 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.)),
|
||||
@@ -971,7 +1040,6 @@ 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;
|
||||
}
|
||||
@@ -1621,7 +1689,7 @@ fn paint_node(
|
||||
}
|
||||
for (relation, row) in state_action_rows(project, node) {
|
||||
if relation.id == selected {
|
||||
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
|
||||
paint_selected_row(frame, row);
|
||||
}
|
||||
label_text(
|
||||
frame,
|
||||
@@ -1637,7 +1705,7 @@ fn paint_node(
|
||||
}
|
||||
for (relation, row) in internal_rows(project, node) {
|
||||
if relation.id == selected {
|
||||
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
|
||||
paint_selected_row(frame, row);
|
||||
}
|
||||
frame.stroke(
|
||||
&line(row.position(), Point::new(row.x + row.width, row.y)),
|
||||
@@ -1672,6 +1740,22 @@ fn paint_node(
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_selected_row(frame: &mut Frame, row: Rectangle) {
|
||||
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
|
||||
if row.width <= 2. || row.height <= 2. {
|
||||
return;
|
||||
}
|
||||
// Selection remains visible without hue. Keep the outline inside the
|
||||
// existing row so it cannot cover its separator or change its hit bounds.
|
||||
frame.stroke(
|
||||
&Path::rectangle(
|
||||
row.position() + Vector::new(1., 1.),
|
||||
Size::new(row.width - 2., row.height - 2.),
|
||||
),
|
||||
Stroke::default().with_color(style::TEAL).with_width(1.5),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum Tool {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Remove(crate::app::diagram_removal::Intent),
|
||||
Choose(Tool),
|
||||
CreateView(Id),
|
||||
Select(Context, Id),
|
||||
@@ -70,6 +71,7 @@ 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, _)
|
||||
|
||||
@@ -139,11 +139,14 @@ 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,
|
||||
));
|
||||
entries.push(
|
||||
item(
|
||||
"Hide from this diagram",
|
||||
Message::Command(Command::Delete(node.id)),
|
||||
true,
|
||||
)
|
||||
.shortcut("Delete"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id)
|
||||
@@ -184,7 +187,30 @@ 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));
|
||||
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)
|
||||
|| super::state_canvas::visible_row_relation(
|
||||
project,
|
||||
context.diagram,
|
||||
&projection::project_view(project, context.diagram),
|
||||
id,
|
||||
))
|
||||
}) {
|
||||
if request.placement.is_some() {
|
||||
"Shift+Delete"
|
||||
} else {
|
||||
"Delete / Shift+Delete"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
|
||||
@@ -141,12 +141,16 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
label: format!("{} · {}", p.named(element), p.elements[&element].kind()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Keep the optional picker in its own stable slot. Inserting it as a
|
||||
// sibling before the canvas would replace the positional Iced focus tree.
|
||||
let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 };
|
||||
let mut missing_controls = Column::new();
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
y: (90. - state.pan.y) / state.zoom,
|
||||
};
|
||||
out = out.push(
|
||||
missing_controls = missing_controls.push(
|
||||
pick_list(missing, None::<Choice>, move |c| {
|
||||
event(Event::Place {
|
||||
context,
|
||||
@@ -176,7 +180,20 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
out = out.push(canvas.map(event));
|
||||
out = out.push(
|
||||
column![
|
||||
missing_controls,
|
||||
crate::diagram::focus::wrap(
|
||||
canvas.map(event),
|
||||
context,
|
||||
crate::app::diagram_removal::focus_enabled(app),
|
||||
app.busy,
|
||||
Message::DiagramTraverse,
|
||||
)
|
||||
]
|
||||
.spacing(missing_spacing)
|
||||
.height(Fill),
|
||||
);
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
|
||||
@@ -87,7 +87,6 @@ pub struct State {
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
pub(crate) focused: bool,
|
||||
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
@@ -129,7 +128,6 @@ impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.tool,
|
||||
self.focused,
|
||||
self.shelf,
|
||||
self.viewport,
|
||||
self.pan,
|
||||
@@ -1556,7 +1554,7 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
.with_color(if n.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
style::BOUNDARY
|
||||
})
|
||||
.with_width(if n.id == self.selected { 2.5 } else { 1. }),
|
||||
);
|
||||
@@ -1663,7 +1661,7 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
.with_color(if selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
style::BOUNDARY
|
||||
})
|
||||
.with_width(if selected { 2.5 } else { 1. }),
|
||||
);
|
||||
@@ -2233,7 +2231,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_disclosures_and_focus_preserve_rejected_raw_draft_and_accepted_source() {
|
||||
fn process_zoom_and_disclosures_preserve_rejected_raw_draft_and_accepted_source() {
|
||||
let (p, owner, _, _, _, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
@@ -2248,16 +2246,19 @@ mod tests {
|
||||
let before = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.behavior.prepare_pending(app.project()).is_err());
|
||||
for (shelf, expected) in [
|
||||
(Shelf::Selection, Some(Shelf::Selection)),
|
||||
(Shelf::Selection, None),
|
||||
for (shelf, expected, zoom) in [
|
||||
(Shelf::Selection, Some(Shelf::Selection), 0.6),
|
||||
(Shelf::Selection, None, 1.2),
|
||||
] {
|
||||
let focused = app.behavior.topology.focused;
|
||||
let _ = app.update(event(Event::Shelf(shelf)));
|
||||
assert_eq!(app.behavior.topology.shelf, expected);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert_eq!(app.behavior.topology.focused, !focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(zoom),
|
||||
)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert_eq!(app.behavior.topology.zoom, Some(zoom));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), before);
|
||||
assert!(app.behavior.prepare_pending(app.project()).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
|
||||
@@ -30,6 +30,7 @@ pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
Remove(Context, Id),
|
||||
SelectQualified(Context, OccurrenceEndpoint),
|
||||
ExpandQualified(Context, OccurrenceEndpoint),
|
||||
OpenQualified(Context, OccurrenceEndpoint),
|
||||
@@ -61,7 +62,6 @@ pub enum CanvasEvent {
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
@@ -70,6 +70,7 @@ impl CanvasEvent {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::Remove(c, ..)
|
||||
| Self::SelectQualified(c, ..)
|
||||
| Self::ExpandQualified(c, ..)
|
||||
| Self::OpenQualified(c, ..)
|
||||
@@ -79,7 +80,6 @@ impl CanvasEvent {
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Camera(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
@@ -507,6 +507,12 @@ impl Input {
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
||||
id != owner
|
||||
&& super::super::selected_behavior(self.project, id) == Some(owner)
|
||||
&& (self.nodes.iter().any(|node| node.id == id)
|
||||
|| self.edges.iter().any(|edge| edge.id == id))
|
||||
}
|
||||
fn ordered_path_at(&self, point: Point) -> Option<&OrderedPath> {
|
||||
self.ordered_paths.iter().find(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
@@ -635,6 +641,29 @@ impl Graph<'_> {
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete),
|
||||
modifiers,
|
||||
repeat: false,
|
||||
..
|
||||
}) if editable
|
||||
&& tool == CanvasTool::Select
|
||||
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
&& state.press.is_none()
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.removal_target(context.owner, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::TopologyCanvas(CanvasEvent::Remove(
|
||||
context,
|
||||
self.selected,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
@@ -952,6 +981,55 @@ impl Graph<'_> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selecting(&self) -> bool {
|
||||
self.tool == CanvasTool::Select
|
||||
}
|
||||
pub(crate) fn removal_target(&self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
self.qualified_selection(p, owner).is_none()
|
||||
&& Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded)
|
||||
.removal_target(owner, id)
|
||||
}
|
||||
pub(crate) fn has_qualified_selection(&self, p: &Project, owner: Id) -> bool {
|
||||
self.qualified_selection(p, owner).is_some()
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn removal_test_point(
|
||||
&self,
|
||||
app: &App,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
qualified: Option<&OccurrenceEndpoint>,
|
||||
) -> Point {
|
||||
let graph = Graph::expanded_qualified(
|
||||
app.project(),
|
||||
owner,
|
||||
id,
|
||||
&self.expanded,
|
||||
&self.contextual_expanded,
|
||||
);
|
||||
let point = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == id)
|
||||
.map(|node| {
|
||||
qualified.map_or(
|
||||
Point::new(node.rect.x + 20., node.rect.y + 25.),
|
||||
|endpoint| node.endpoint_rect(endpoint).center(),
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == id)
|
||||
.map(|edge| edge.label.center())
|
||||
})
|
||||
.expect("rendered process target");
|
||||
Point::ORIGIN
|
||||
+ self.pan
|
||||
+ iced::Vector::new(point.x, point.y)
|
||||
* self.scale(graph.size, (app.window_size.width - 600.).max(200.))
|
||||
}
|
||||
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
|
||||
@@ -18,6 +18,15 @@ async fn production_process_canvas_scaled_path_creation_preserves_source_and_edi
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = size;
|
||||
mounted.settle(&mut app).await;
|
||||
let geometry = mounted.geometry(&app);
|
||||
let scope = canvas_workspace::tests::scope(&app);
|
||||
for removed in ["Canvas", "Panels"] {
|
||||
assert!(!geometry.containers.contains_key(&scope.control_id(removed)));
|
||||
}
|
||||
assert!(
|
||||
!canvas_workspace::tests::focused(&mut mounted, &app, "viewport"),
|
||||
"direct editing needs no activation or initial keyboard focus grab"
|
||||
);
|
||||
for scale in [0.5, 1., 2.] {
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
@@ -209,7 +218,7 @@ async fn production_process_camera_external_controls_bounds_and_retained_deltas_
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let snapshot = app.document.snapshot();
|
||||
let source = app.document.bundle().clone();
|
||||
for boundary in ["Fit", "OR", "Details", "Panels", "Bounds"] {
|
||||
for boundary in ["Fit", "OR", "Details", "ZoomIn", "Bounds"] {
|
||||
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let point = bounds.position() + iced::Vector::new(95., 90.);
|
||||
@@ -399,10 +408,7 @@ async fn production_process_keyboard_enters_activates_draws_focus_and_returns_to
|
||||
)));
|
||||
assert_eq!(app.behavior.topology.pan, iced::Vector::ZERO);
|
||||
assert!(focused(&mut mounted, &app, "Fit"));
|
||||
tab_to(&mut mounted, &mut app, "Canvas").await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
tab_to(&mut mounted, &mut app, "viewport").await;
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
@@ -596,6 +602,17 @@ async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_r
|
||||
let snapshot = app.document.snapshot();
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await;
|
||||
assert!(
|
||||
mounted
|
||||
.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.is_empty(),
|
||||
"a second F6 cannot enter while the first focus operation is pending"
|
||||
);
|
||||
// Native Iced completes the Chain synchronously. Complete it before a new
|
||||
// keyboard request, retaining the same Apply to challenge its ownership
|
||||
// guard again after it has already observed a valid owner.
|
||||
mounted.operate(&app, &mut *retained_apply);
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable);
|
||||
assert_eq!(entry.len(), 1);
|
||||
let delayed = app.update(entry[0].clone());
|
||||
@@ -705,6 +722,8 @@ async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_r
|
||||
);
|
||||
|
||||
let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await;
|
||||
mounted.operate(&app, &mut *retained_apply);
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable);
|
||||
let delayed = app.update(
|
||||
entry
|
||||
|
||||
@@ -247,45 +247,12 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
.style(style::card)
|
||||
.into();
|
||||
let header = column![
|
||||
row![
|
||||
out.width(Fill),
|
||||
super::super::canvas_workspace::Control::new(
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.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),
|
||||
out.width(Fill),
|
||||
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
|
||||
)
|
||||
|
||||
@@ -737,6 +737,25 @@ impl<Message: Clone> overlay::Overlay<Message, Theme, Renderer> for MenuOverlay<
|
||||
},
|
||||
Color::from_rgb8(218, 238, 235),
|
||||
);
|
||||
if row.width > 2. && row.height > 2. {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle {
|
||||
x: row.x + 1.,
|
||||
y: row.y + 1.,
|
||||
width: row.width - 2.,
|
||||
height: row.height - 2.,
|
||||
},
|
||||
border: Border {
|
||||
color: style::TEAL,
|
||||
width: 1.5,
|
||||
radius: 4.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
let color = if message.is_some() {
|
||||
style::INK
|
||||
@@ -792,7 +811,7 @@ impl<Message: Clone> overlay::Overlay<Message, Theme, Renderer> for MenuOverlay<
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
style::LINE,
|
||||
style::BOUNDARY,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1103,6 +1122,145 @@ impl<'a, Message: 'a> From<Area<'a, Message>> for Element<'a, Message> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pixel_contrast_on_white(pixel: &[u8]) -> f32 {
|
||||
let linear = |byte: u8| {
|
||||
let value = f32::from(byte) / 255.;
|
||||
if value <= 0.04045 {
|
||||
value / 12.92
|
||||
} else {
|
||||
((value + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
1.05 / (0.2126 * linear(pixel[0])
|
||||
+ 0.7152 * linear(pixel[1])
|
||||
+ 0.0722 * linear(pixel[2])
|
||||
+ 0.05)
|
||||
}
|
||||
|
||||
fn target_rule_coverage(pixels: &[u8], width: usize, row: Rectangle) -> f32 {
|
||||
// Only the lower padding, away from text, corners and scroll thumb.
|
||||
// A pale fill or scattered text pixels cannot satisfy a continuous cue.
|
||||
let left = (row.x + 14.).ceil() as usize;
|
||||
let right = (row.x + row.width - 20.).floor() as usize;
|
||||
let count = (left..right)
|
||||
.filter(|x| {
|
||||
((row.y + row.height - 4.).ceil() as usize
|
||||
..(row.y + row.height - 1.).ceil() as usize)
|
||||
.any(|y| {
|
||||
let index = (y * width + x) * 4;
|
||||
pixel_contrast_on_white(&pixels[index..index + 4]) >= 3.
|
||||
})
|
||||
})
|
||||
.count();
|
||||
count as f32 / (right - left) as f32
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_keyboard_menu_paints_target_and_scroll_thumb_then_invokes_that_action() {
|
||||
use iced::advanced::{overlay::Overlay as _, renderer::Headless};
|
||||
use iced_runtime::user_interface::{Cache, UserInterface};
|
||||
let mut renderer = <Renderer as Headless>::new(iced::Font::DEFAULT, 13.into(), None)
|
||||
.await
|
||||
.expect("custom menu paint test requires WGPU headless rendering");
|
||||
let mut menu = menu();
|
||||
menu.anchor = Point::new(20., 20.);
|
||||
menu.entries
|
||||
.extend((30..70).map(|id| Entry::action(format!("Other action {id}"), id)));
|
||||
let size = Size::new(480., 420.);
|
||||
let mut measured_state = State::default();
|
||||
let mut measured = MenuOverlay {
|
||||
menu: &menu,
|
||||
state: &mut measured_state,
|
||||
};
|
||||
let node = measured.layout(&renderer, size);
|
||||
let geometry = measured.geometry(Layout::new(&node));
|
||||
let row = |index: usize| {
|
||||
geometry.row(
|
||||
menu.entries[..index].iter().map(Entry::height).sum(),
|
||||
menu.entries[index].height(),
|
||||
0.,
|
||||
)
|
||||
};
|
||||
let thumb = geometry.scroll_thumb(0.).expect("fixture must overflow");
|
||||
let mut ui = UserInterface::build(
|
||||
Popup::new(
|
||||
iced::widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
Some(menu.clone()),
|
||||
),
|
||||
size,
|
||||
Cache::new(),
|
||||
&mut renderer,
|
||||
);
|
||||
let cursor = mouse::Cursor::Unavailable;
|
||||
let mut messages = vec![];
|
||||
let theme = style::theme();
|
||||
let paint_style = renderer::Style {
|
||||
text_color: style::INK,
|
||||
};
|
||||
for (movement, active, inactive) in
|
||||
[(None, 1, 4), (Some(keyboard::key::Named::ArrowDown), 4, 1)]
|
||||
{
|
||||
if let Some(movement) = movement {
|
||||
let _ = ui.update(
|
||||
&[key(movement)],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"moving the target must not invoke an action"
|
||||
);
|
||||
}
|
||||
// Building an Iced UI does not lay out its overlay. The normal
|
||||
// update pass must initialize it before draw can paint the menu.
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(messages.is_empty(), "layout cannot invoke an action");
|
||||
ui.draw(&mut renderer, &theme, &paint_style, cursor);
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(480, 420), 1., Color::WHITE);
|
||||
assert!(
|
||||
target_rule_coverage(&pixels, 480, row(active)) > 0.8,
|
||||
"the keyboard target needs a continuous high-contrast shape cue"
|
||||
);
|
||||
for index in [inactive, 2, 5] {
|
||||
assert!(
|
||||
target_rule_coverage(&pixels, 480, row(index)) < 0.2,
|
||||
"ordinary and disabled rows must not acquire the target cue"
|
||||
);
|
||||
}
|
||||
let point = thumb.center();
|
||||
let index = (point.y.floor() as usize * 480 + point.x.floor() as usize) * 4;
|
||||
assert!(
|
||||
pixel_contrast_on_white(&pixels[index..index + 4]) >= 3.,
|
||||
"the actual overflow thumb must be visible without hover"
|
||||
);
|
||||
}
|
||||
let _ = ui.update(
|
||||
&[
|
||||
key(keyboard::key::Named::Enter),
|
||||
key(keyboard::key::Named::Enter),
|
||||
],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert_eq!(
|
||||
messages,
|
||||
[22],
|
||||
"Enter invokes exactly the painted Properties target once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_area_does_not_swallow_first_independent_click_after_overlay_consumed_its_release() {
|
||||
let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
|
||||
|
||||
@@ -34,9 +34,15 @@ 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,
|
||||
@@ -53,8 +59,9 @@ 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)>,
|
||||
@@ -66,6 +73,7 @@ pub struct State {
|
||||
cache: RefCell<Option<CachedScene>>,
|
||||
}
|
||||
struct Scene {
|
||||
work: Option<Arc<crate::app::allocation_drag::Preview>>,
|
||||
view: ProjectedView,
|
||||
routed: Arc<RoutedView>,
|
||||
}
|
||||
@@ -452,6 +460,26 @@ 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 {
|
||||
@@ -498,7 +526,11 @@ impl DiagramCanvas<'_> {
|
||||
route_diagram(self.project, &diagram, &view)
|
||||
},
|
||||
);
|
||||
let scene = Arc::new(Scene { view, routed });
|
||||
let scene = Arc::new(Scene {
|
||||
work: None,
|
||||
view,
|
||||
routed,
|
||||
});
|
||||
*state.cache.borrow_mut() = Some(CachedScene {
|
||||
context: self.context,
|
||||
preview,
|
||||
@@ -529,7 +561,26 @@ 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;
|
||||
@@ -595,9 +646,6 @@ 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) =>
|
||||
@@ -617,7 +665,8 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key, modifiers, ..
|
||||
}) if state.keyboard_focus => {
|
||||
}) => {
|
||||
// The mounted focus::Owner is the sole keyboard admission gate.
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
match key.as_ref() {
|
||||
Key::Named(Named::F2)
|
||||
@@ -672,10 +721,37 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
Key::Named(Named::Delete)
|
||||
if self.editable
|
||||
&& self.connection.is_none()
|
||||
&& modifiers.is_empty()
|
||||
&& self.selected_bend.is_some() =>
|
||||
&& !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) =>
|
||||
{
|
||||
Some(Action::publish(Message::Route(RouteEvent::RemoveBend)).and_capture())
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -716,6 +792,22 @@ 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(
|
||||
@@ -797,6 +889,16 @@ 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
|
||||
@@ -835,6 +937,18 @@ 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));
|
||||
@@ -867,6 +981,18 @@ 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());
|
||||
@@ -1013,6 +1139,10 @@ 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
|
||||
@@ -1021,10 +1151,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.collect::<BTreeSet<_>>();
|
||||
for n in &view.nodes {
|
||||
if containers.contains(&n.id) {
|
||||
paint_node(&mut f, self.project, n, self.selected, true);
|
||||
paint_node(&mut f, project, n, self.selected, true);
|
||||
}
|
||||
}
|
||||
let highlighted = highlighted_exchanges(self.project, self.selected);
|
||||
let highlighted = highlighted_exchanges(project, self.selected);
|
||||
for edge in &view.edges {
|
||||
let Some(routed) = scene.routed.edges.get(&edge.id) else {
|
||||
continue;
|
||||
@@ -1045,9 +1175,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
} else if edge.trace {
|
||||
Color::from_rgb8(134, 107, 168)
|
||||
} else {
|
||||
Color::from_rgb8(144, 158, 173)
|
||||
style::BOUNDARY
|
||||
};
|
||||
let capability_label = relationship_stereotype(self.project, edge.id);
|
||||
let capability_label = relationship_stereotype(project, edge.id);
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
@@ -1095,7 +1225,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(relation) = self.project.relations.get(&edge.id) {
|
||||
if let Some(relation) = 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);
|
||||
@@ -1129,13 +1259,13 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
paint_node(&mut f, self.project, n, self.selected, false);
|
||||
paint_node(&mut f, 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, self.project, &header, self.selected, true);
|
||||
paint_node(&mut f, project, &header, self.selected, true);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
@@ -1143,17 +1273,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::kind_color(
|
||||
self.project.elements[&port.element]
|
||||
project.elements[&port.element]
|
||||
.parent
|
||||
.and_then(|id| self.project.elements.get(&id))
|
||||
.and_then(|id| 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(&self.project.elements[&port.element]);
|
||||
let glyph = port_glyph(&project.elements[&port.element]);
|
||||
let path = if glyph == PortGlyph::Function {
|
||||
let corners = port_triangle(&self.project.elements[&port.element], port);
|
||||
let corners = port_triangle(&project.elements[&port.element], port);
|
||||
Path::new(|p| {
|
||||
p.move_to(corners[0]);
|
||||
p.line_to(corners[1]);
|
||||
@@ -1183,7 +1313,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
if matches!(glyph, PortGlyph::Flow(_)) {
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
for [a, b] in flow_mark(&self.project.elements[&port.element], port) {
|
||||
for [a, b] in flow_mark(&project.elements[&port.element], port) {
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
}
|
||||
@@ -1353,7 +1483,8 @@ 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.drag.is_some()
|
||||
} else if state.work.is_some()
|
||||
|| state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
|| state.route_drag.is_some()
|
||||
|| state.port_drag.is_some()
|
||||
@@ -1475,7 +1606,11 @@ fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, conta
|
||||
};
|
||||
f.fill(&path, if container { tint } else { Color::WHITE });
|
||||
let mut stroke = Stroke::default()
|
||||
.with_color(if e.id == selected { color } else { style::LINE })
|
||||
.with_color(if e.id == selected {
|
||||
color
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
})
|
||||
.with_width(if e.id == selected { 2.6 } else { 1.2 });
|
||||
if n.allocation_summary {
|
||||
stroke.line_dash = canvas::LineDash {
|
||||
@@ -1910,6 +2045,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
@@ -1985,6 +2121,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
@@ -2111,6 +2248,7 @@ 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| {
|
||||
@@ -2184,6 +2322,7 @@ 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
|
||||
@@ -2213,6 +2352,7 @@ mod tests {
|
||||
// A tool cancellation or navigation invalidates a queued release.
|
||||
let cancelled = DiagramCanvas {
|
||||
connection: None,
|
||||
allocation: None,
|
||||
context: Context {
|
||||
epoch: 1,
|
||||
..context
|
||||
@@ -2285,6 +2425,7 @@ 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.);
|
||||
@@ -2361,6 +2502,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let pin = Point::new(350., 250.);
|
||||
@@ -2458,6 +2600,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let first = canvas.scene(&state);
|
||||
@@ -2586,6 +2729,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
for end in ends {
|
||||
assert!(svg.contains(&format!(
|
||||
@@ -2640,6 +2784,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state);
|
||||
@@ -2714,6 +2859,7 @@ 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)
|
||||
@@ -2747,6 +2893,7 @@ 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());
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! 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,
|
||||
can_focus: bool,
|
||||
suspended: bool,
|
||||
traversing: bool,
|
||||
pointer: Option<Point>,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
if self.can_focus {
|
||||
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,
|
||||
suspended: 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,
|
||||
suspended: bool,
|
||||
traverse: impl Fn(bool) -> Message + 'a,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Owner {
|
||||
context,
|
||||
enabled,
|
||||
suspended,
|
||||
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),
|
||||
can_focus: self.enabled && !self.suspended,
|
||||
suspended: self.suspended,
|
||||
..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>();
|
||||
let suspend_started = self.suspended && !state.suspended;
|
||||
state.suspended = self.suspended;
|
||||
state.can_focus = self.enabled && !self.suspended;
|
||||
// A new model revision retires pointer intent, not keyboard ownership
|
||||
// of the same canvas. Ordinary edits and Undo/Redo keep that focus.
|
||||
if state.context != Some(self.context) {
|
||||
state.pointer = None;
|
||||
}
|
||||
if state.context.is_none_or(|previous| {
|
||||
(previous.document, previous.diagram, previous.epoch)
|
||||
!= (self.context.document, self.context.diagram, self.context.epoch)
|
||||
}) || !self.enabled
|
||||
{
|
||||
state.focused = false;
|
||||
state.traversing = false;
|
||||
state.pointer = None;
|
||||
}
|
||||
state.context = Some(self.context);
|
||||
if suspend_started {
|
||||
// A release may arrive while I/O owns commands. Retire the wrapped
|
||||
// canvas's transient gestures without retiring this focus owner.
|
||||
state.pointer = None;
|
||||
tree.children[0] = Tree::new(self.content.as_widget());
|
||||
} 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,
|
||||
) {
|
||||
// Suspended focus can still be revoked by another field's operation.
|
||||
// Input::focus denies acquiring it until the passive task completes.
|
||||
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 = (state.focused || !self.suspended)
|
||||
&& 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))
|
||||
});
|
||||
}
|
||||
// Save/export I/O denies commands and acquisition, while real focus
|
||||
// operations and outside clicks can still revoke the retained owner.
|
||||
if self.suspended && matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_)) {
|
||||
return;
|
||||
}
|
||||
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 && !self.suspended {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
fn screen(app: &App, id: Id) -> Point {
|
||||
|
||||
@@ -73,6 +73,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
fn event(
|
||||
|
||||
@@ -19,6 +19,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ 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![];
|
||||
}
|
||||
@@ -186,6 +189,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Passive frozen target outlines. Destination identity is in the toolbar so
|
||||
/// feedback cannot cover candidate text or change the target hit geometry.
|
||||
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.),
|
||||
);
|
||||
}
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ use std::{fmt, sync::Arc};
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[path = "document_alias_rename.rs"]
|
||||
mod alias_rename;
|
||||
pub use alias_rename::PreparedAliasRename;
|
||||
|
||||
struct HistoryEntry {
|
||||
snapshot: Arc<CompiledSourceProject>,
|
||||
bytes: usize,
|
||||
@@ -91,6 +95,8 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
// Distinguishes equal accepted snapshots belonging to different documents.
|
||||
alias_owner: Arc<()>,
|
||||
current: Arc<CompiledSourceProject>,
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
@@ -121,6 +127,7 @@ impl Document {
|
||||
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
|
||||
let current_bytes = estimated_bytes(¤t);
|
||||
Ok(Self {
|
||||
alias_owner: Arc::new(()),
|
||||
current,
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
@@ -394,8 +401,17 @@ impl Document {
|
||||
return false;
|
||||
}
|
||||
let bytes = estimated_bytes(&compiled);
|
||||
self.commit_snapshot(Arc::new(compiled), bytes)
|
||||
}
|
||||
|
||||
// Only validated internal paths supply a changed snapshot and its exact
|
||||
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
|
||||
// path checks its private changed flag and exact accepted-snapshot authority.
|
||||
// Alias preparation shares this algorithm while retaining the live save and
|
||||
// history state, and does not clone/recompile the candidate on adoption.
|
||||
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
|
||||
self.past.push(HistoryEntry {
|
||||
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
|
||||
snapshot: std::mem::replace(&mut self.current, compiled),
|
||||
bytes: std::mem::replace(&mut self.current_bytes, bytes),
|
||||
});
|
||||
self.future.clear();
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
|
||||
//! not authorize adoption; only this module can mint a prepared rename.
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A pinned intent minted by its owning Document. Move it to a worker and call
|
||||
/// `prepare`; preparation has no access to the live Document or its histories.
|
||||
#[derive(Debug)]
|
||||
pub struct AliasRenameRequest {
|
||||
owner: Arc<()>,
|
||||
before: Arc<CompiledSourceProject>,
|
||||
revision: u64,
|
||||
limits: ProjectLimits,
|
||||
target: Id,
|
||||
old_alias: String,
|
||||
new_alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedData {
|
||||
request: AliasRenameRequest,
|
||||
after: Arc<CompiledSourceProject>,
|
||||
before_bytes: usize,
|
||||
after_bytes: usize,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Immutable, Clone-compatible transport for preview and approval. Neither
|
||||
/// mutable candidate access nor deserialization can manufacture this authority.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedAliasRename(Arc<PreparedData>);
|
||||
|
||||
impl PreparedAliasRename {
|
||||
pub fn before(&self) -> &CompiledSourceProject {
|
||||
&self.0.request.before
|
||||
}
|
||||
|
||||
pub fn after(&self) -> &CompiledSourceProject {
|
||||
&self.0.after
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Id {
|
||||
self.0.request.target
|
||||
}
|
||||
|
||||
pub fn old_alias(&self) -> &str {
|
||||
&self.0.request.old_alias
|
||||
}
|
||||
|
||||
pub fn new_alias(&self) -> &str {
|
||||
&self.0.request.new_alias
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.0.request.revision
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.0.changed
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(message: &str) -> DocumentError {
|
||||
ModelError(message.into()).into()
|
||||
}
|
||||
|
||||
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
|
||||
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn alias_rename_request(
|
||||
&self,
|
||||
target: Id,
|
||||
replacement: String,
|
||||
) -> Result<AliasRenameRequest, DocumentError> {
|
||||
if self.revision == u64::MAX {
|
||||
return Err(refusal(
|
||||
"The document revision is exhausted. Reopen before renaming.",
|
||||
));
|
||||
}
|
||||
let old_alias = self
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?;
|
||||
// A candidate has a nonzero estimate. Avoid expensive preparation when
|
||||
// even the current snapshot leaves no room for its immediate Undo.
|
||||
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(AliasRenameRequest {
|
||||
owner: self.alias_owner.clone(),
|
||||
before: self.current.clone(),
|
||||
revision: self.revision,
|
||||
limits: self.limits,
|
||||
target,
|
||||
old_alias,
|
||||
new_alias: replacement,
|
||||
})
|
||||
}
|
||||
|
||||
/// App must separately consume its current approval and check workspace,
|
||||
/// invocation and draft freshness. This checks accepted Document authority.
|
||||
pub fn commit_alias_rename(
|
||||
&mut self,
|
||||
prepared: &PreparedAliasRename,
|
||||
) -> Result<bool, DocumentError> {
|
||||
let data = &prepared.0;
|
||||
let request = &data.request;
|
||||
if self.revision == u64::MAX
|
||||
|| self.revision != request.revision
|
||||
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|
||||
|| !Arc::ptr_eq(&self.current, &request.before)
|
||||
{
|
||||
return Err(refusal(
|
||||
"This rename preview is stale. Preview the current alias again.",
|
||||
));
|
||||
}
|
||||
if !data.changed {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.current_bytes != data.before_bytes {
|
||||
return Err(refusal(
|
||||
"The rename history estimate is stale. Preview again.",
|
||||
));
|
||||
}
|
||||
check_history_capacity(self.current_bytes, data.after_bytes)?;
|
||||
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl AliasRenameRequest {
|
||||
/// Run on the preparation worker. The typed command and source updater are
|
||||
/// the only candidate construction path, followed by alias-only invariants.
|
||||
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
|
||||
let before_project = &self.before.decoded.project;
|
||||
let candidate = prepare_command(
|
||||
before_project,
|
||||
&Command::SetAlias(self.target, self.new_alias.clone()),
|
||||
)?;
|
||||
let mut expected = before_project.clone();
|
||||
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
|
||||
let identities: BTreeMap<_, _> = before_project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
if candidate.project != expected || candidate.identity_map != identities {
|
||||
return Err(refusal(
|
||||
"Alias preparation changed unrelated model data or identities.",
|
||||
));
|
||||
}
|
||||
let after = source_project::update_bundle_with_identity_map(
|
||||
&self.before,
|
||||
&candidate.project,
|
||||
&candidate.identity_map,
|
||||
self.limits,
|
||||
)?;
|
||||
let before = &self.before;
|
||||
if after.decoded.project != expected
|
||||
|| before.bundle.entry != after.bundle.entry
|
||||
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|
||||
|| before.index.lexical_owners != after.index.lexical_owners
|
||||
|| !before
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path))
|
||||
.eq(after
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path)))
|
||||
{
|
||||
return Err(refusal(
|
||||
"Alias preparation changed source membership or ownership.",
|
||||
));
|
||||
}
|
||||
let changed = before.bundle != after.bundle;
|
||||
let before_bytes = estimated_bytes(before);
|
||||
let after_bytes = estimated_bytes(&after);
|
||||
if changed {
|
||||
check_history_capacity(before_bytes, after_bytes)?;
|
||||
}
|
||||
Ok(PreparedAliasRename(Arc::new(PreparedData {
|
||||
request: self,
|
||||
after: Arc::new(after),
|
||||
before_bytes,
|
||||
after_bytes,
|
||||
changed,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// Independent full-model expectation: SetAlias may change exactly one header
|
||||
// field, across every supported identity family. Do not reuse command execution
|
||||
// to derive the expected result, which would hide collateral command changes.
|
||||
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
|
||||
if project.id == id {
|
||||
project.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.elements.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.relations.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.diagrams.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.artifact_links.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.traces.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else {
|
||||
project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.find_map(|diagram| diagram.nodes.get_mut(&id))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?
|
||||
.alias = alias.to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "document_alias_rename_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,459 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> Document {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), r#"arcadia 7;
|
||||
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
|
||||
include "work.arcadia"; include "views.arcadia";
|
||||
}
|
||||
"#.replace('\n', "\r\n")),
|
||||
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
|
||||
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
|
||||
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
|
||||
assess; }
|
||||
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
|
||||
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
|
||||
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
|
||||
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
|
||||
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
|
||||
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no migration or ID insertion"
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
|
||||
document
|
||||
.alias_rename_request(target, replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Capture every live Document state relevant to refusal/no-op. Arc identity also
|
||||
// catches replacing an equal snapshot or saved marker behind the user's back.
|
||||
struct Unchanged {
|
||||
current: Arc<CompiledSourceProject>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
past: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
future: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
revision: u64,
|
||||
current_bytes: usize,
|
||||
recovered: bool,
|
||||
}
|
||||
|
||||
impl Unchanged {
|
||||
fn capture(document: &Document) -> Self {
|
||||
Self {
|
||||
current: document.snapshot(),
|
||||
saved: document.saved.clone(),
|
||||
past: document
|
||||
.past
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
future: document
|
||||
.future
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
revision: document.revision,
|
||||
current_bytes: document.current_bytes,
|
||||
recovered: document.recovered_unsaved,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert(&self, document: &Document) {
|
||||
assert!(Arc::ptr_eq(&self.current, &document.current));
|
||||
match (&self.saved, &document.saved) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("saved baseline changed"),
|
||||
}
|
||||
for (before, after) in [
|
||||
(&self.past, &document.past),
|
||||
(&self.future, &document.future),
|
||||
] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for ((snapshot, bytes), entry) in before.iter().zip(after) {
|
||||
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
|
||||
assert_eq!(*bytes, entry.bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(self.revision, document.revision);
|
||||
assert_eq!(self.current_bytes, document.current_bytes);
|
||||
assert_eq!(self.recovered, document.recovered_unsaved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
|
||||
// Literal role-specific replacements are independent of the updater/tokenizer.
|
||||
// They deliberately exclude prose, historical review text and URI spelling.
|
||||
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
|
||||
let cases: &[AliasCase<'_>] = &[
|
||||
(
|
||||
1,
|
||||
"mission",
|
||||
"renamed",
|
||||
&[("project mission ", "project renamed ")],
|
||||
),
|
||||
(
|
||||
2,
|
||||
"assess",
|
||||
"renamed",
|
||||
&[
|
||||
("oa_activity assess ", "oa_activity renamed "),
|
||||
("\r\n assess;", "\r\n renamed;"),
|
||||
("@assess;", "@renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
5,
|
||||
"transfer",
|
||||
"renamed",
|
||||
&[
|
||||
("interaction transfer ", "interaction renamed "),
|
||||
("\r\n transfer;", "\r\n renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
6,
|
||||
"review",
|
||||
"renamed",
|
||||
&[
|
||||
("trace review ", "trace renamed "),
|
||||
("owner @review;", "owner @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
7,
|
||||
"evidence",
|
||||
"renamed",
|
||||
&[("artifact evidence ", "artifact renamed ")],
|
||||
),
|
||||
(
|
||||
8,
|
||||
"overview",
|
||||
"renamed",
|
||||
&[
|
||||
("diagram overview ", "diagram renamed "),
|
||||
("diagram @overview;", "diagram @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
9,
|
||||
"placement",
|
||||
"renamed",
|
||||
&[("node placement ", "node renamed ")],
|
||||
),
|
||||
];
|
||||
for &(number, old, new, replacements) in cases {
|
||||
let mut document = fixture();
|
||||
let original = document.snapshot();
|
||||
let mut expected_model = document.project().clone();
|
||||
// Explicit per-family oracle, separate from production expectation helper.
|
||||
match number {
|
||||
1 => expected_model.alias = new.into(),
|
||||
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
|
||||
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
|
||||
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
|
||||
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
|
||||
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
|
||||
9 => {
|
||||
expected_model
|
||||
.diagrams
|
||||
.get_mut(&id(8))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(9))
|
||||
.unwrap()
|
||||
.alias = new.into()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut expected_bundle = document.bundle().clone();
|
||||
for source in expected_bundle.files.values_mut() {
|
||||
for &(before, after) in replacements {
|
||||
*source = source.replace(before, after);
|
||||
}
|
||||
}
|
||||
let state = Unchanged::capture(&document);
|
||||
let prepared = prepare(&document, id(number), new);
|
||||
state.assert(&document);
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.old_alias(), old);
|
||||
assert_eq!(prepared.new_alias(), new);
|
||||
assert_eq!(prepared.target(), id(number));
|
||||
assert_eq!(prepared.revision(), 0);
|
||||
assert_eq!(prepared.before().bundle, original.bundle);
|
||||
assert_eq!(prepared.after().bundle, expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, expected_model);
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert!(
|
||||
Arc::ptr_eq(&document.current, &prepared.0.after),
|
||||
"adopt the exact prepared Arc"
|
||||
);
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.dirty());
|
||||
let after = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
|
||||
after.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &original));
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &expected_bundle);
|
||||
assert_eq!(document.project(), &expected_model);
|
||||
document.mark_saved(document.snapshot());
|
||||
let reopened =
|
||||
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected_model);
|
||||
assert_eq!(reopened.bundle(), &expected_bundle);
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
|
||||
assert!(matches!(
|
||||
language::tokenize("oa_activity")[0].class,
|
||||
language::TokenClass::Keyword
|
||||
));
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "oa_activity");
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
|
||||
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
|
||||
let mut document = fixture();
|
||||
let first = prepare(&document, id(2), "changed");
|
||||
document.commit_alias_rename(&first).unwrap();
|
||||
assert!(document.undo());
|
||||
document.invalidate_saved_baseline();
|
||||
document.mark_recovered();
|
||||
let state = Unchanged::capture(&document);
|
||||
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
state.assert(&document);
|
||||
}
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(Id::nil(), "new".into())
|
||||
.is_err()
|
||||
);
|
||||
let no_op = prepare(&document, id(2), "assess");
|
||||
assert!(!no_op.changed());
|
||||
assert!(!document.commit_alias_rename(&no_op).unwrap());
|
||||
state.assert(&document);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
|
||||
let mut document = fixture();
|
||||
let old = prepare(&document, id(2), "renamed");
|
||||
let mut second = fixture();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
// Even an identical shared accepted Arc cannot confer this Document's owner.
|
||||
second.current = document.current.clone();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Later name".into()))
|
||||
.unwrap();
|
||||
let edited = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
edited.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
|
||||
let undone = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
undone.assert(&document);
|
||||
document.revision = u64::MAX - 1;
|
||||
let last = prepare(&document, id(2), "renamed");
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Exhaust revision".into()))
|
||||
.unwrap();
|
||||
document.undo();
|
||||
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
|
||||
let exhausted = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&last).is_err());
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_err()
|
||||
);
|
||||
exhausted.assert(&document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_retains_latest_saved_baseline_and_recovery_state() {
|
||||
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let original = document.snapshot();
|
||||
match baseline {
|
||||
"saved_before" => document.mark_saved(original.clone()),
|
||||
"unknown" => document.invalidate_saved_baseline(),
|
||||
"raw_repair" => {
|
||||
let mut raw = document.bundle().clone();
|
||||
raw.files
|
||||
.insert("project.arcadia".into(), "invalid repair source".into());
|
||||
document.bind_repair_baseline(raw);
|
||||
}
|
||||
"recovered" => document.mark_recovered(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let saved = document.saved.clone();
|
||||
let recovered = document.recovered_unsaved;
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert!(document.dirty());
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert_eq!(document.dirty(), baseline != "saved_before");
|
||||
}
|
||||
// A completed save after Preview remains authoritative, including when it
|
||||
// clears recovery; a worker must not restore its earlier state on Confirm.
|
||||
let mut document = fixture();
|
||||
document.mark_recovered();
|
||||
document.invalidate_saved_baseline();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
document.mark_saved(document.snapshot());
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert!(!document.recovered_unsaved);
|
||||
document.undo();
|
||||
assert!(!document.dirty());
|
||||
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
|
||||
document.redo();
|
||||
document.mark_saved(document.snapshot());
|
||||
document.undo();
|
||||
let return_to_saved = prepare(&document, id(2), "renamed");
|
||||
document.commit_alias_rename(&return_to_saved).unwrap();
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let exact = prepared.after().clone();
|
||||
let mut counterfeit = prepared.after().clone();
|
||||
counterfeit.decoded.project.name = "Injected unrelated edit".into();
|
||||
counterfeit.index.declarations.clear();
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(document.apply_compiled(counterfeit).is_err());
|
||||
unchanged.assert(&document);
|
||||
let mut unrelated = exact.decoded.project.clone();
|
||||
unrelated.name = "A self-consistent unrelated candidate".into();
|
||||
let forged_dto = source_project::compile_bundle(
|
||||
&source_project::format_bundle(&unrelated),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
|
||||
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
|
||||
// leaves the opaque capability and its exact adopted model/source intact.
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project(), &exact.decoded.project);
|
||||
assert_eq!(document.bundle(), &exact.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
|
||||
let mut document = fixture();
|
||||
let mut bundle = document.bundle().clone();
|
||||
let padding = HISTORY_BYTES / 2 - document.current_bytes;
|
||||
bundle
|
||||
.files
|
||||
.get_mut("views.arcadia")
|
||||
.unwrap()
|
||||
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
|
||||
document.apply_bundle(bundle).unwrap();
|
||||
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
|
||||
// A same-length alias retains the exact estimate; older history is evictable.
|
||||
let boundary = prepare(&document, id(2), "rating");
|
||||
assert_eq!(
|
||||
boundary.0.before_bytes + boundary.0.after_bytes,
|
||||
HISTORY_BYTES
|
||||
);
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "longer_alias".into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
document.commit_alias_rename(&boundary).unwrap();
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
|
||||
assert!(check_history_capacity(usize::MAX, 1).is_err());
|
||||
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
// Inject cache corruption to falsify the commit-side check independently of
|
||||
// the preparation admission. Normal edits also change revision and Arc.
|
||||
document.current_bytes = HISTORY_BYTES;
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(document.commit_alias_rename(&prepared).is_err());
|
||||
// No-op intent needs no Undo reservation even at the admission boundary.
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_ok()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
}
|
||||
@@ -295,7 +295,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Mes
|
||||
color: if focus || cursor.is_over(layout.bounds()) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
style::BOUNDARY
|
||||
},
|
||||
width: if focus { 2. } else { 1. },
|
||||
radius: 5.into(),
|
||||
|
||||
@@ -708,7 +708,7 @@ impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, T
|
||||
renderer::Quad {
|
||||
bounds: geometry.popup,
|
||||
border: Border {
|
||||
color: style::LINE,
|
||||
color: style::BOUNDARY,
|
||||
width: 1.,
|
||||
radius: 8.into(),
|
||||
},
|
||||
@@ -751,6 +751,22 @@ impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, T
|
||||
},
|
||||
Color::from_rgb8(218, 238, 235),
|
||||
);
|
||||
// Labels begin at row.x. Keep the target cue below their
|
||||
// two text lines without changing measured or hit bounds.
|
||||
if row.width > 4. && row.height > 3. {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle {
|
||||
x: row.x + 2.,
|
||||
y: row.y + row.height - 3.,
|
||||
width: row.width - 4.,
|
||||
height: 2.,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
let alias_width = (row.width * 0.42).min(
|
||||
measure::<R>(
|
||||
@@ -992,3 +1008,181 @@ fn raw_label<R: text::Renderer<Font = Font>>(
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paint_tests {
|
||||
use super::super::{Candidate, Context, Current, Outcome};
|
||||
use super::*;
|
||||
use iced::advanced::renderer::Headless;
|
||||
use iced_runtime::user_interface::{Cache, UserInterface};
|
||||
use oa_core::Id;
|
||||
|
||||
fn named(key: keyboard::key::Named) -> Event {
|
||||
Event::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,
|
||||
})
|
||||
}
|
||||
|
||||
fn view(chooser: &Chooser, anchor: Rectangle) -> Element<'_, ChoiceEvent> {
|
||||
Popup::new(
|
||||
iced::widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
Some(chooser),
|
||||
anchor,
|
||||
std::convert::identity,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn lower_rule_coverage(pixels: &[u8], row: Rectangle) -> f32 {
|
||||
let linear = |byte: u8| {
|
||||
let value = f32::from(byte) / 255.;
|
||||
if value <= 0.04045 {
|
||||
value / 12.92
|
||||
} else {
|
||||
((value + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let left = (row.x + 14.).ceil() as usize;
|
||||
let right = (row.x + row.width - 14.).floor() as usize;
|
||||
let count = (left..right)
|
||||
.filter(|x| {
|
||||
((row.y + row.height - 4.).ceil() as usize
|
||||
..(row.y + row.height - 1.).ceil() as usize)
|
||||
.any(|y| {
|
||||
let index = (y * 480 + x) * 4;
|
||||
let pixel = &pixels[index..index + 4];
|
||||
let luminance = 0.2126 * linear(pixel[0])
|
||||
+ 0.7152 * linear(pixel[1])
|
||||
+ 0.0722 * linear(pixel[2]);
|
||||
1.05 / (luminance + 0.05) >= 3.
|
||||
})
|
||||
})
|
||||
.count();
|
||||
count as f32 / (right - left) as f32
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_chooser_keyboard_target_is_visible_and_enter_keeps_exact_identity() {
|
||||
let mut renderer = <iced::Renderer as Headless>::new(Font::DEFAULT, 13.into(), None)
|
||||
.await
|
||||
.expect("custom chooser paint test requires WGPU headless rendering");
|
||||
let context = Context::live(
|
||||
Id::from_u128(1),
|
||||
7,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
"/body/SequenceUse/sequence",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut chooser = Chooser::open(
|
||||
context.clone(),
|
||||
[(13, "Gamma"), (12, "Beta"), (11, "Alpha")]
|
||||
.into_iter()
|
||||
.map(|(id, name)| Candidate {
|
||||
id: Id::from_u128(id),
|
||||
name: name.into(),
|
||||
alias: format!("candidate_{id}"),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "System / supply topology".into(),
|
||||
})
|
||||
.collect(),
|
||||
Current::Unassigned,
|
||||
"SA · compatible functions",
|
||||
)
|
||||
.unwrap();
|
||||
let anchor = Rectangle {
|
||||
x: 20.,
|
||||
y: 20.,
|
||||
width: 300.,
|
||||
height: 30.,
|
||||
};
|
||||
let size = Size::new(480., 420.);
|
||||
let geometry = Geometry::new(anchor, size);
|
||||
let cursor = mouse::Cursor::Unavailable;
|
||||
let theme = style::theme();
|
||||
let paint_style = renderer::Style {
|
||||
text_color: style::INK,
|
||||
};
|
||||
let mut cache = Cache::new();
|
||||
let mut messages = vec![];
|
||||
for active in 0..2 {
|
||||
let mut ui = UserInterface::build(view(&chooser, anchor), size, cache, &mut renderer);
|
||||
// Every controlled rebuild starts without a retained overlay layout.
|
||||
// Run the normal update pass before observing the rendered popup.
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(messages.is_empty(), "layout cannot browse or choose");
|
||||
ui.draw(&mut renderer, &theme, &paint_style, cursor);
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(480, 420), 1., Color::WHITE);
|
||||
for index in 0..3 {
|
||||
let coverage = lower_rule_coverage(&pixels, geometry.row(index));
|
||||
if index == active {
|
||||
assert!(
|
||||
coverage > 0.8,
|
||||
"the actual keyboard candidate needs a continuous shape cue"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
coverage < 0.2,
|
||||
"the previous and ordinary candidates must not retain the cue"
|
||||
);
|
||||
}
|
||||
}
|
||||
let events = if active == 0 {
|
||||
vec![named(keyboard::key::Named::ArrowDown)]
|
||||
} else {
|
||||
vec![
|
||||
named(keyboard::key::Named::Enter),
|
||||
named(keyboard::key::Named::Enter),
|
||||
]
|
||||
};
|
||||
let _ = ui.update(
|
||||
&events,
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
cache = ui.into_cache();
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
1,
|
||||
"one movement or one exact choice, with no repeated Enter"
|
||||
);
|
||||
let outcome = chooser.update(messages.pop().unwrap());
|
||||
if active == 0 {
|
||||
assert_eq!(outcome, Outcome::Browsed);
|
||||
assert_eq!(chooser.highlighted().unwrap().id, Id::from_u128(12));
|
||||
assert_eq!(
|
||||
chooser.current,
|
||||
Current::Unassigned,
|
||||
"navigation cannot write a reference"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Outcome::Write {
|
||||
context: context.clone(),
|
||||
value: Some(Id::from_u128(12))
|
||||
}
|
||||
);
|
||||
assert!(!chooser.is_open());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; use + Relationship with Allocation to assign existing work. The allocated-work view shows work inside its performer.",
|
||||
"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.",
|
||||
"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. 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.",
|
||||
"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.",
|
||||
"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 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 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",
|
||||
sections: &[
|
||||
explain(
|
||||
"Three things to distinguish",
|
||||
@@ -309,14 +309,22 @@ 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"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, including a collapsed performer's header. Read Destination above the canvas and the outlined target 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 work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. 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",
|
||||
&[
|
||||
@@ -336,7 +344,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. 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.",
|
||||
"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.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -445,7 +453,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"On an empty path, choose Work (W) from the right palette and click the canvas. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
|
||||
"Choose Work (W) or Exchange (E) from the permanent palette on the right, then a highlighted position. Hover a palette button to read its tool name. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"Choose Sequence link (->) and click or drag from the preceding contribution to the following contribution. Choose Select contributions or paths (S) to drag a selected path endpoint and reconnect it. A contribution's ↕ handle reorders contributions within the same column; links determine the columns. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
@@ -455,7 +463,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
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.",
|
||||
"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. Click in the canvas to give it wheel and keyboard focus; pan with middle-button drag or Space + left drag. F6 enters or leaves canvas keyboard 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",
|
||||
@@ -468,7 +476,7 @@ fn article(topic: Topic) -> Article {
|
||||
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.",
|
||||
"Select a contribution directly inside a reusable preview, then open Details to read its complete caller and reference identity. The explorer and properties remain available alongside the canvas.",
|
||||
"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 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.",
|
||||
@@ -488,7 +496,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use a scenario to tell a concrete behavioral story, including repeated messages, actions, state observations and guarded alternatives.",
|
||||
keywords: "scenario sequence lifeline lifetime lifecycle birth death capsule sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
|
||||
keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Create a scenario",
|
||||
@@ -514,14 +522,6 @@ fn article(topic: Topic) -> Article {
|
||||
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Read lifeline creation and deletion",
|
||||
"The Lane header and dotted guide identify a lifeline; the guide alone does not establish its lifetime. The stronger solid line shows where the lifeline is known to exist on this scenario path. Create starts that line at a named capsule; Delete ends it at an X. Select the capsule or X to edit that message occurrence. Select the header or guide to inspect the lifeline.",
|
||||
),
|
||||
explain(
|
||||
"Distinguish uncertain lifetime from an unanswered call",
|
||||
"A ? means lifetime depends on an alternative, optional, repeated or parallel path. Operands have independent lifetimes: Delete in one alternative does not delete a lifeline in another. Beyond such a fragment, the guide can continue without a definite solid line. This uncertainty is not by itself a model error. The … beside an activation marks a Call that remains unanswered on that path. A reused scenario's internal Create/Delete events appear when you choose Open definition; they do not create or delete the caller's lifelines.",
|
||||
),
|
||||
walkthrough(
|
||||
"Invoke an interface operation",
|
||||
&[
|
||||
@@ -570,7 +570,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Navigate and recover a view",
|
||||
"Middle-button drag pans; scroll or use −/+ to zoom, and Fit frames the diagram. Right-click a container for Fold contents or Unfold contents, or choose Placement properties for exact coordinates and size. Hide from this diagram removes only its placement; Show an element not yet in this view restores missing elements. Escape cancels a drawing gesture. Undo restores each completed gesture or menu edit, and Model checks identifies unfinished structures.",
|
||||
"Middle-button drag pans; scroll or use −/+ to zoom, and Fit frames the diagram. Right-click a container for Fold contents or Unfold contents, or choose Placement properties for exact coordinates and size. With the canvas focused, Delete hides a selected state placement; Shift+Delete confirms model removal. Delete on an action or internal-transition row confirms removal of that relation, preserving its states and referenced work. Escape cancels a drawing gesture. Undo restores each completed edit, and Model checks identifies unfinished structures.",
|
||||
),
|
||||
explain(
|
||||
"One saved diagram across editors",
|
||||
@@ -805,7 +805,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename F2 preview diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
@@ -834,6 +834,15 @@ fn article(topic: Topic) -> Article {
|
||||
"Names, aliases and IDs serve different purposes",
|
||||
"The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.",
|
||||
),
|
||||
walkthrough(
|
||||
"Rename a source alias",
|
||||
&[
|
||||
"Place the caret on a declaration alias or live @reference, then choose Rename alias or press F2 in the source editor. First finish or revert pending source, property and behavior drafts; close or reattach any extra loaded detached files. Accepted edits do not need to be saved first.",
|
||||
"Edit Source alias and choose Preview (Enter also previews). Pasting line breaks, tabs or other control characters is rejected without changing the field; choose Preview again after correcting the paste. Review the changed files, hunks and lines; long lines have additional segments. The engineering name and UUID stay unchanged. Comments and quoted prose keep their original spelling. This preview covers the rename; a later Save can include earlier accepted edits too.",
|
||||
"Choose Rename alias to accept the complete preview, or Cancel/Escape to leave everything unchanged. An invalid, unchanged, stale or oversized preview cannot be confirmed. After editing the replacement, choose Preview again. If the source context changes, close the form and invoke Rename again on the current alias.",
|
||||
"Use Undo rename immediately, or the model Undo/Redo controls, to restore the complete source/model snapshot. Ctrl+Z in Text still undoes source drafting. Save all and reopen use the normal project workflow; an unconfirmed preview is not saved.",
|
||||
],
|
||||
),
|
||||
example(
|
||||
"Readable references",
|
||||
"For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.",
|
||||
@@ -990,6 +999,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Shortcuts act on the current project. In Textual model, undo and redo affect the source draft.",
|
||||
"Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save all\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog",
|
||||
),
|
||||
explain(
|
||||
"Remove a diagram item",
|
||||
"Click the diagram to give it keyboard focus. Delete hides an architecture or state placement; Shift+Delete asks to remove its model element. A selected route bend is removed first. Process and scenario items, links, and state action rows use confirmation for either key because they have no separate placement. Removing an occurrence preserves its reusable definition. Removing a scenario participant retains its messages with affected participant references cleared; removing a fragment also removes its owned branches and steps. Read the captured target and effect before confirming. Escape cancels, and Undo restores the complete change. Inside an expanded reusable process, open its definition to edit its contents, or select the outer reference to remove that use. Text fields keep their normal Delete behavior.",
|
||||
),
|
||||
walkthrough(
|
||||
"Save and share a model",
|
||||
&[
|
||||
|
||||
@@ -61,13 +61,13 @@ impl Highlighter for ArcadiaHighlighter {
|
||||
|
||||
pub fn format(class: &TokenClass, _theme: &Theme) -> Format<Font> {
|
||||
let color = match class {
|
||||
TokenClass::Keyword => Color::from_rgb8(118, 62, 160),
|
||||
TokenClass::Identifier => Color::from_rgb8(20, 93, 139),
|
||||
TokenClass::String => Color::from_rgb8(24, 113, 79),
|
||||
TokenClass::Number => Color::from_rgb8(163, 78, 30),
|
||||
TokenClass::Comment => Color::from_rgb8(111, 121, 132),
|
||||
TokenClass::Error => Color::from_rgb8(186, 38, 62),
|
||||
TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(91, 101, 117),
|
||||
TokenClass::Keyword => Color::from_rgb8(51, 23, 72),
|
||||
TokenClass::Identifier => Color::from_rgb8(4, 39, 61),
|
||||
TokenClass::String => Color::from_rgb8(4, 42, 27),
|
||||
TokenClass::Number => Color::from_rgb8(63, 26, 6),
|
||||
TokenClass::Comment => Color::from_rgb8(33, 37, 41),
|
||||
TokenClass::Error => Color::from_rgb8(76, 9, 20),
|
||||
TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(32, 37, 44),
|
||||
};
|
||||
Format {
|
||||
color: Some(color),
|
||||
|
||||
@@ -67,10 +67,12 @@ fn dialog(app: &App) -> Element<'_, Message> {
|
||||
let mut content = column![
|
||||
label("SERVICE ADDRESS"),
|
||||
text_input("http://127.0.0.1:8787", &state.base)
|
||||
.style(style::selectable_input)
|
||||
.padding(10)
|
||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
|
||||
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
|
||||
text_input("Optional for a loopback service", &state.token.0)
|
||||
.style(style::selectable_input)
|
||||
.secure(true)
|
||||
.padding(10)
|
||||
.on_input_maybe(
|
||||
@@ -173,6 +175,7 @@ fn dialog(app: &App) -> Element<'_, Message> {
|
||||
content = content.push(text("Hosted model catalog").size(17)).push(
|
||||
row![
|
||||
text_input("Search model names", &state.query)
|
||||
.style(style::selectable_input)
|
||||
.padding(9)
|
||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
|
||||
.on_submit(Message::Hosted(Event::Connect)),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
mod allocation_drag;
|
||||
mod allocation_input;
|
||||
mod app;
|
||||
mod behavior;
|
||||
mod connection_edit;
|
||||
@@ -25,6 +27,7 @@ mod recovery;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_navigation;
|
||||
mod source_rename;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
|
||||
@@ -420,7 +420,9 @@ 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));
|
||||
entries.push(
|
||||
item("Hide from this diagram", Message::HideSelected, true).shortcut("Delete"),
|
||||
);
|
||||
} else if e.kind() != Kind::Port
|
||||
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
|
||||
{
|
||||
@@ -470,7 +472,40 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
true,
|
||||
));
|
||||
if id != p.id {
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
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 if app.selected == id
|
||||
&& (app.topology_context().is_some_and(|context| {
|
||||
app.behavior.topology.selecting()
|
||||
&& app.behavior.topology.removal_target(p, context.owner, id)
|
||||
}) || app.scenario_context().is_some_and(|context| {
|
||||
app.behavior.scenario.canvas_tool(context.scenario)
|
||||
== crate::behavior::scenario::CanvasTool::Select
|
||||
&& crate::behavior::scenario::removal_target(
|
||||
p,
|
||||
context.scenario,
|
||||
id,
|
||||
)
|
||||
}))
|
||||
{
|
||||
"Delete / Shift+Delete"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
mod controller;
|
||||
pub mod interaction;
|
||||
pub mod query;
|
||||
pub mod selection;
|
||||
pub mod symbols;
|
||||
pub use controller::*;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
|
||||
@@ -220,12 +220,22 @@ pub struct ReferenceRow {
|
||||
pub evidence: Evidence,
|
||||
}
|
||||
|
||||
/// Exact lexical occurrence that answered the query, independent of row limits.
|
||||
/// The optional @ marker is separate from the alias; comments between them do
|
||||
/// not become part of either hit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Origin {
|
||||
pub span: Span,
|
||||
pub marker: Option<Span>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Results {
|
||||
pub stamp: QueryStamp,
|
||||
pub action: Action,
|
||||
pub origin_path: String,
|
||||
pub origin_offset: usize,
|
||||
pub origin: Option<Origin>,
|
||||
pub alias: Option<String>,
|
||||
pub scope: Scope,
|
||||
pub disposition: Disposition,
|
||||
@@ -823,10 +833,28 @@ impl Snapshot {
|
||||
.ok_or(QueryError::MissingFile)?;
|
||||
let index = self.index()?;
|
||||
let hit = index.hit(file, query.byte_offset)?;
|
||||
let alias = match hit {
|
||||
Some(symbols::Hit::Definition(handle)) => Some(index.definition(handle)?.alias),
|
||||
Some(symbols::Hit::Reference(handle)) => Some(index.reference(handle)?.alias),
|
||||
None => None,
|
||||
let (alias, origin) = match hit {
|
||||
Some(symbols::Hit::Definition(handle)) => {
|
||||
let definition = index.definition(handle)?;
|
||||
(
|
||||
Some(definition.alias),
|
||||
Some(Origin {
|
||||
span: definition.alias_location.span,
|
||||
marker: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some(symbols::Hit::Reference(handle)) => {
|
||||
let reference = index.reference(handle)?;
|
||||
(
|
||||
Some(reference.alias),
|
||||
Some(Origin {
|
||||
span: reference.alias_location.span,
|
||||
marker: Some(reference.at.span),
|
||||
}),
|
||||
)
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let mut budget = RowBudget {
|
||||
rows: self.limits.result_rows,
|
||||
@@ -840,6 +868,7 @@ impl Snapshot {
|
||||
action: query.action,
|
||||
origin_path: query.path.to_owned(),
|
||||
origin_offset: query.byte_offset,
|
||||
origin,
|
||||
alias: alias.map(str::to_owned),
|
||||
scope: self.scope.clone(),
|
||||
disposition: Disposition::NoSymbol,
|
||||
|
||||
@@ -107,6 +107,65 @@ fn reference_offset(fixture: &Fixture) -> usize {
|
||||
fixture.text("links.arcadia").find("source @").unwrap() + "source ".len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_keeps_exact_hit_when_display_rows_are_omitted() {
|
||||
let mut fixture = Fixture::standard();
|
||||
fixture
|
||||
.text_mut("links.arcadia")
|
||||
.push_str("// no @pump hit\r\n@pump @pump @ // intervening @pump\r\n pump");
|
||||
let source = fixture.text("links.arcadia");
|
||||
let start = source.rfind("pump").unwrap();
|
||||
let marker = source.rfind("@ // intervening").unwrap();
|
||||
let limits = Limits {
|
||||
result_rows: 0,
|
||||
..Limits::default()
|
||||
};
|
||||
let snapshot = fixture.snapshot(None, false, limits);
|
||||
let expected = Some(Origin {
|
||||
span: Span {
|
||||
start,
|
||||
end: start + 4,
|
||||
},
|
||||
marker: Some(Span {
|
||||
start: marker,
|
||||
end: marker + 1,
|
||||
}),
|
||||
});
|
||||
for offset in [start, start + 3, marker] {
|
||||
let result = query(&snapshot, "links.arcadia", offset, Action::References);
|
||||
assert_eq!(result.origin, expected);
|
||||
assert!(result.references.is_empty());
|
||||
assert!(result.omitted_references > 2);
|
||||
}
|
||||
let comment = source.rfind("intervening @pump").unwrap() + "intervening @".len();
|
||||
assert_eq!(
|
||||
query(&snapshot, "links.arcadia", comment, Action::Definition).origin,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
query(&snapshot, "links.arcadia", start + 4, Action::Definition).origin,
|
||||
None
|
||||
);
|
||||
let definition_source = fixture.text("模块/活動.arcadia");
|
||||
let definition_start = definition_source.find("pump").unwrap();
|
||||
assert_eq!(
|
||||
query(
|
||||
&snapshot,
|
||||
"模块/活動.arcadia",
|
||||
definition_start,
|
||||
Action::Definition
|
||||
)
|
||||
.origin,
|
||||
Some(Origin {
|
||||
span: Span {
|
||||
start: definition_start,
|
||||
end: definition_start + 4
|
||||
},
|
||||
marker: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_multifile_unicode_crlf_definition_and_repeated_lexical_references() {
|
||||
let fixture = Fixture::standard();
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Effective selected-byte ranges for a committed departure from a source view.
|
||||
//!
|
||||
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
|
||||
//! endpoints can therefore save an empty range for a visibly selected word/line.
|
||||
//! Capture only after navigation admission and destination validation succeed,
|
||||
//! immediately before replacing/leaving the live editor. Never use this to inspect
|
||||
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
|
||||
use iced::advanced::{graphics, text};
|
||||
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
|
||||
/// Capture the effective range for restoration on fresh Content via `move_to`.
|
||||
///
|
||||
/// A nonempty selection is collapsed to its start in the departing Content; text
|
||||
/// is not edited. The returned Normal range preserves selected bytes and typing
|
||||
/// replacement, not Word/Line gesture-extension mode or an interior click point.
|
||||
/// Unequal raw endpoint direction is preserved; coincident Word/Line endpoints
|
||||
/// canonically restore with the active cursor at the effective end.
|
||||
///
|
||||
/// The renderer constraint is intentional: the inspected Iced graphics editor
|
||||
/// collapses any selection to its effective start for `Move(Left)`, and its copied
|
||||
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
|
||||
/// Only the selected text is temporarily copied; retained state is one Cursor.
|
||||
pub fn capture<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
let original = content.cursor();
|
||||
let Some(selected) = content.selection().filter(|selected| !selected.is_empty()) else {
|
||||
// Moving an unselected caret Left would be a user-visible navigation.
|
||||
return original;
|
||||
};
|
||||
let active_start = original.selection.is_some_and(|anchor| {
|
||||
(original.position.line, original.position.column) < (anchor.line, anchor.column)
|
||||
});
|
||||
content.perform(Action::Move(Motion::Left));
|
||||
let start = content.cursor().position;
|
||||
let end = match selected.rsplit_once('\n') {
|
||||
Some((prefix, suffix)) => Position {
|
||||
line: start.line + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
|
||||
column: suffix.len(),
|
||||
},
|
||||
None => Position {
|
||||
line: start.line,
|
||||
column: start.column + selected.len(),
|
||||
},
|
||||
};
|
||||
// These sums describe an existing selection in this same allocated buffer;
|
||||
// neither can exceed its line count / existing UTF-8 line length. A checked
|
||||
// error after collapsing would leave a failed departure with lost selection.
|
||||
debug_assert!(content.cursor().selection.is_none());
|
||||
debug_assert!(start.line < content.line_count() && end.line < content.line_count());
|
||||
debug_assert!((start.line, start.column) < (end.line, end.column));
|
||||
Cursor {
|
||||
position: if active_start { start } else { end },
|
||||
selection: Some(if active_start { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::widget::text_editor::Edit;
|
||||
use std::sync::Arc;
|
||||
|
||||
type EditorContent = Content<iced::Renderer>;
|
||||
|
||||
fn at(line: usize, column: usize) -> Position {
|
||||
Position { line, column }
|
||||
}
|
||||
|
||||
fn range(start: Position, end: Position, active_start: bool) -> Cursor {
|
||||
Cursor {
|
||||
position: if active_start { start } else { end },
|
||||
selection: Some(if active_start { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
fn caret(content: &mut EditorContent, position: Position) {
|
||||
content.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn roundtrip(
|
||||
source: &str,
|
||||
prepare: impl Fn(&mut EditorContent),
|
||||
selected: &str,
|
||||
expected: Cursor,
|
||||
lossy_raw: bool,
|
||||
) {
|
||||
let mut departing = EditorContent::with_text(source);
|
||||
prepare(&mut departing);
|
||||
assert_eq!(departing.text(), source);
|
||||
assert_eq!(departing.selection().as_deref(), Some(selected));
|
||||
let raw = departing.cursor();
|
||||
|
||||
// Independent direct-typing control retains the original semantic mode.
|
||||
let replacement = Action::Edit(Edit::Paste(Arc::new("替🚀".into())));
|
||||
let mut direct = EditorContent::with_text(source);
|
||||
prepare(&mut direct);
|
||||
direct.perform(replacement.clone());
|
||||
|
||||
let captured = capture(&mut departing);
|
||||
assert_eq!(captured, expected);
|
||||
assert_eq!(
|
||||
departing.text(),
|
||||
source,
|
||||
"capture cannot edit physical source bytes"
|
||||
);
|
||||
assert!(departing.cursor().selection.is_none());
|
||||
let mut restored = EditorContent::with_text(source);
|
||||
restored.move_to(captured);
|
||||
assert_eq!(restored.selection().as_deref(), Some(selected));
|
||||
assert_eq!(restored.text(), source);
|
||||
restored.perform(replacement);
|
||||
assert_eq!(
|
||||
restored.text(),
|
||||
direct.text(),
|
||||
"restored range must replace exactly the original selected bytes"
|
||||
);
|
||||
assert_eq!(restored.cursor(), direct.cursor());
|
||||
if lossy_raw {
|
||||
let mut old = EditorContent::with_text(source);
|
||||
old.move_to(raw);
|
||||
assert_ne!(
|
||||
old.selection().as_deref(),
|
||||
Some(selected),
|
||||
"negative control must discriminate raw cursor storage"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
captured, raw,
|
||||
"ordinary directional ranges are already canonical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_normal_ranges_preserve_forward_and_reverse_direction() {
|
||||
let source = "先 αβ 救助 🚀 tail\r\n";
|
||||
for active_start in [false, true] {
|
||||
let expected = range(
|
||||
at(0, "先 ".len()),
|
||||
at(0, "先 αβ 救助 🚀".len()),
|
||||
active_start,
|
||||
);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.move_to(expected),
|
||||
"αβ 救助 🚀",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_unicode_words_capture_the_selected_occurrence_without_searching() {
|
||||
let source = "éclair éclair éclair\r\n";
|
||||
let start = "éclair ".len();
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, at(0, start + "écl".len()));
|
||||
content.perform(Action::SelectWord);
|
||||
},
|
||||
"éclair",
|
||||
range(at(0, start), at(0, start + "éclair".len()), false),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_selection_expands_past_its_interior_unicode_caret() {
|
||||
let source = "first\r\n救助 🚀 tail\r\nlast";
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, at(1, "救助 ".len()));
|
||||
content.perform(Action::SelectLine);
|
||||
},
|
||||
"救助 🚀 tail",
|
||||
range(at(1, 0), at(1, "救助 🚀 tail".len()), false),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_ranges_use_copied_lf_and_utf8_columns_for_physical_crlf_and_mixed_endings() {
|
||||
for source in ["pré α\r\n中 🚀\r\nfin Ω", "pré α\n中 🚀\r\nfin Ω"] {
|
||||
for active_start in [false, true] {
|
||||
let expected = range(at(0, "pré ".len()), at(2, "fin ".len()), active_start);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.move_to(expected),
|
||||
"α\n中 🚀\nfin ",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_word_and_line_modes_capture_multiline_ranges_in_both_directions() {
|
||||
let source = "one alpha\r\ntwo beta\nthree gamma";
|
||||
for line in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let (start, end, selected) = match (line, reverse) {
|
||||
(false, false) => (at(0, 4), at(2, 11), "alpha\ntwo beta\nthree gamma"),
|
||||
(false, true) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
|
||||
(true, _) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
|
||||
};
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, if reverse { at(2, 8) } else { at(0, 6) });
|
||||
content.perform(if line {
|
||||
Action::SelectLine
|
||||
} else {
|
||||
Action::SelectWord
|
||||
});
|
||||
// Iced preserves existing Word/Line mode for Select motions.
|
||||
content.perform(Action::Select(if reverse {
|
||||
Motion::DocumentStart
|
||||
} else {
|
||||
Motion::DocumentEnd
|
||||
}));
|
||||
},
|
||||
selected,
|
||||
range(start, end, reverse),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_newline_selection_restores_endpoint_on_the_empty_last_line() {
|
||||
let source = "雨\r\n🚀\r\n";
|
||||
let expected = range(at(0, 0), at(2, 0), false);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.perform(Action::SelectAll),
|
||||
"雨\n🚀\n",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_selection_do_not_move_the_departing_caret() {
|
||||
for source in ["", "\r\n", "alpha\r\n\r\ntail"] {
|
||||
for empty_selection in [false, true] {
|
||||
let mut content = EditorContent::with_text(source);
|
||||
let position = if source.starts_with("alpha") {
|
||||
at(1, 0)
|
||||
} else {
|
||||
at(0, 0)
|
||||
};
|
||||
caret(&mut content, position);
|
||||
if empty_selection {
|
||||
content.perform(Action::SelectLine);
|
||||
assert_eq!(content.selection().as_deref(), Some(""));
|
||||
} else {
|
||||
assert!(content.selection().is_none());
|
||||
}
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection();
|
||||
assert_eq!(capture(&mut content), raw);
|
||||
assert_eq!(content.cursor(), raw);
|
||||
assert_eq!(content.selection(), selected);
|
||||
assert_eq!(content.text(), source);
|
||||
}
|
||||
}
|
||||
for empty_normal in [false, true] {
|
||||
let mut content = EditorContent::with_text("éclair");
|
||||
let position = at(0, "écl".len());
|
||||
content.move_to(Cursor {
|
||||
position,
|
||||
selection: empty_normal.then_some(position),
|
||||
});
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection();
|
||||
assert_eq!(capture(&mut content), raw);
|
||||
assert_eq!(
|
||||
content.cursor(),
|
||||
raw,
|
||||
"an unselected interior caret must not move left"
|
||||
);
|
||||
assert_eq!(content.selection(), selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Pure preview and exact origin mapping for the guarded alias-rename controller.
|
||||
pub mod origin;
|
||||
pub mod preview;
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Map a role-proven alias occurrence through the exact prepared source change.
|
||||
//! This is cursor restoration, not target resolution or commit authority.
|
||||
use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass};
|
||||
use std::fmt;
|
||||
|
||||
const LIMITS: OutlineLimits = OutlineLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_tokens: 1_000_000,
|
||||
max_declarations: 250_000,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OriginError {
|
||||
Budget,
|
||||
InvalidSource,
|
||||
MissingOrigin,
|
||||
ChangedStructure,
|
||||
}
|
||||
|
||||
impl fmt::Display for OriginError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.",
|
||||
Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.",
|
||||
Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.",
|
||||
Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OriginError {}
|
||||
|
||||
fn word(class: TokenClass) -> bool {
|
||||
matches!(class, TokenClass::Identifier | TokenClass::Keyword)
|
||||
}
|
||||
|
||||
/// The controller passes the exact alias span returned by the admitted symbol
|
||||
/// hit. `marker` is Some only for a reference (its separate @ span), including
|
||||
/// invocation with the caret on @. A declaration passes None.
|
||||
pub fn map(
|
||||
before: &str,
|
||||
after: &str,
|
||||
old_alias: &str,
|
||||
new_alias: &str,
|
||||
origin: Span,
|
||||
marker: Option<Span>,
|
||||
) -> Result<Span, OriginError> {
|
||||
let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
if old.has_lexical_errors || new.has_lexical_errors {
|
||||
return Err(OriginError::InvalidSource);
|
||||
}
|
||||
if old.tokens.len() != new.tokens.len() {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
let roles_before = roles(&old);
|
||||
let roles_after = roles(&new);
|
||||
let mut mapped = None;
|
||||
for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() {
|
||||
let left = &before[a.span.start..a.span.end];
|
||||
let right = &after[b.span.start..b.span.end];
|
||||
let alias_pair = word(a.class)
|
||||
&& word(b.class)
|
||||
&& left == old_alias
|
||||
&& right == new_alias
|
||||
&& roles_before[index].is_some()
|
||||
&& roles_after[index].is_some()
|
||||
&& roles_before[index].map(|role| role.is_some())
|
||||
== roles_after[index].map(|role| role.is_some());
|
||||
if a.span == origin {
|
||||
if !alias_pair || roles_before[index] != Some(marker) {
|
||||
return Err(OriginError::MissingOrigin);
|
||||
}
|
||||
mapped = Some(b.span);
|
||||
}
|
||||
if !alias_pair && (left != right || a.class != b.class) {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
}
|
||||
mapped.ok_or(OriginError::MissingOrigin)
|
||||
}
|
||||
|
||||
/// Outer None is not an alias role; Some(None) is a declaration alias;
|
||||
/// Some(Some(span)) is a live @ reference with its exact separate marker.
|
||||
/// Scan sorted declaration spans and tokens together, without repeated searches.
|
||||
fn roles(outline: &Outline<'_>) -> Vec<Option<Option<Span>>> {
|
||||
let mut result = vec![None; outline.tokens.len()];
|
||||
let mut declarations = outline.declarations.iter().peekable();
|
||||
let mut significant = None;
|
||||
for (index, token) in outline.tokens.iter().enumerate() {
|
||||
while declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span.start < token.span.start)
|
||||
{
|
||||
declarations.next();
|
||||
}
|
||||
if word(token.class) {
|
||||
if declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span == token.span)
|
||||
{
|
||||
result[index] = Some(None);
|
||||
} else if let Some(previous) = significant {
|
||||
let span: Span = previous;
|
||||
if &outline.source[span.start..span.end] == "@" {
|
||||
result[index] = Some(Some(span));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) {
|
||||
significant = Some(token.span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn occurrence(source: &str, needle: &str, which: usize) -> Span {
|
||||
let start = source.match_indices(needle).nth(which).unwrap().0;
|
||||
Span {
|
||||
start,
|
||||
end: start + needle.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() {
|
||||
let before =
|
||||
"oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n";
|
||||
let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n";
|
||||
let start = before.rfind(" a;").unwrap() + 1;
|
||||
let marker = before.rfind("@ //").unwrap();
|
||||
let mapped = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"renamed",
|
||||
Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
},
|
||||
Some(Span {
|
||||
start: marker,
|
||||
end: marker + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], "renamed");
|
||||
assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1);
|
||||
assert_eq!(
|
||||
before[..start].bytes().filter(|b| *b == b'\n').count(),
|
||||
after[..mapped.start]
|
||||
.bytes()
|
||||
.filter(|b| *b == b'\n')
|
||||
.count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_final_same_line_occurrence_not_first_or_prose() {
|
||||
let before = "oa_activity a \"a @a\" {} source @a; target @a;";
|
||||
let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let result = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
},
|
||||
Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.start, after.rfind("@longer").unwrap() + 1);
|
||||
let prose = occurrence(before, "a @a", 0);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: prose.start,
|
||||
end: prose.start + 1
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_identifier_keyword_transitions_in_alias_roles() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let alias = occurrence(before, "a \"Name", 0);
|
||||
let origin = Span {
|
||||
start: alias.start,
|
||||
end: alias.start + 1,
|
||||
};
|
||||
for replacement in ["none", "include", "oa_activity"] {
|
||||
assert!(oa_core::valid_alias(replacement));
|
||||
assert_eq!(
|
||||
language::tokenize(replacement)[0].class,
|
||||
TokenClass::Keyword
|
||||
);
|
||||
let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};");
|
||||
let mapped = map(before, &after, "a", replacement, origin, None).unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], replacement);
|
||||
assert_eq!(
|
||||
map(&after, before, replacement, "a", mapped, None).unwrap(),
|
||||
origin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_changed_trivia_non_alias_words_and_wrong_marker() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let after = "oa_activity b \"Name\" {} source @b;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let span = Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
};
|
||||
let marker = Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
});
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace("Name", "Other"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace(" source", " source"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(before, after, "a", "b", span, None),
|
||||
Err(OriginError::MissingOrigin)
|
||||
);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"b",
|
||||
Span {
|
||||
start: 0,
|
||||
end: usize::MAX
|
||||
},
|
||||
marker
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Complete, bounded line-ordinal previews of an already prepared alias rename.
|
||||
//!
|
||||
//! Source text remains in the caller's immutable before/after snapshots. This
|
||||
//! module owns only paths and indexes; it is not rename or commit authority.
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{fmt, ops::Range};
|
||||
|
||||
const CONTEXT: usize = 3;
|
||||
// A byte-grid boundary can move back by at most three UTF-8 continuation bytes,
|
||||
// so a 252-byte grid yields fragments of at most 255 bytes, independent of input.
|
||||
const SEGMENT_GRID: usize = 252;
|
||||
|
||||
/// Count all bounded fragments of exact source text; an empty line has one
|
||||
/// empty fragment. Page these as well as lines, never mount an unbounded line.
|
||||
pub fn segment_count(text: &str) -> usize {
|
||||
text.len().div_ceil(SEGMENT_GRID).max(1)
|
||||
}
|
||||
|
||||
/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the
|
||||
/// original bytes, including CRLF. No ellipsis or truncation replaces content.
|
||||
pub fn text_segment(text: &str, index: usize) -> Option<&str> {
|
||||
if index >= segment_count(text) {
|
||||
return None;
|
||||
}
|
||||
let mut start = index.checked_mul(SEGMENT_GRID)?;
|
||||
let mut end = start.saturating_add(SEGMENT_GRID).min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(start..end)
|
||||
}
|
||||
const LIMITS: Limits = Limits {
|
||||
files: 256,
|
||||
changed_lines: 50_000,
|
||||
display_bytes: 8 * 1024 * 1024,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
files: usize,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Preview {
|
||||
files: Vec<File>,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct File {
|
||||
path: String,
|
||||
hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Hunk {
|
||||
lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Line {
|
||||
number: usize,
|
||||
before: Range<usize>,
|
||||
after: Range<usize>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PreviewError {
|
||||
MembershipChanged,
|
||||
LineCountChanged(String),
|
||||
LineEndingChanged { path: String, line: usize },
|
||||
TooManyFiles { limit: usize },
|
||||
TooManyChangedLines { limit: usize },
|
||||
TooManyDisplayBytes { limit: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for PreviewError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MembershipChanged => write!(
|
||||
f,
|
||||
"Rename preview refused: the entry or source file membership changed. No rename was applied."
|
||||
),
|
||||
Self::LineCountChanged(path) => write!(
|
||||
f,
|
||||
"Rename preview refused: the line count changed in {path}. No rename was applied."
|
||||
),
|
||||
Self::LineEndingChanged { path, line } => write!(
|
||||
f,
|
||||
"Rename preview refused: the line ending changed in {path}:{line}. No rename was applied."
|
||||
),
|
||||
Self::TooManyFiles { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyChangedLines { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyDisplayBytes { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PreviewError {}
|
||||
|
||||
impl Preview {
|
||||
pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result<Self, PreviewError> {
|
||||
Self::with_limits(before, after, LIMITS)
|
||||
}
|
||||
|
||||
fn with_limits(
|
||||
before: &SourceBundle,
|
||||
after: &SourceBundle,
|
||||
limits: Limits,
|
||||
) -> Result<Self, PreviewError> {
|
||||
if before.entry != after.entry
|
||||
|| !before.files.contains_key(&before.entry)
|
||||
|| !before.files.keys().eq(after.files.keys())
|
||||
{
|
||||
return Err(PreviewError::MembershipChanged);
|
||||
}
|
||||
let mut preview = Self {
|
||||
files: Vec::new(),
|
||||
changed_lines: 0,
|
||||
display_bytes: 0,
|
||||
};
|
||||
for ((path, old), (_, new)) in before.files.iter().zip(&after.files) {
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
if preview.files.len() == limits.files {
|
||||
return Err(PreviewError::TooManyFiles {
|
||||
limit: limits.files,
|
||||
});
|
||||
}
|
||||
// A changed source line can contain arbitrarily many renamed tokens.
|
||||
// Count the line once; this count is never a semantic-reference count.
|
||||
let mut changed = Vec::new();
|
||||
let mut old_lines = Lines::new(old);
|
||||
let mut new_lines = Lines::new(new);
|
||||
let mut count = 0;
|
||||
loop {
|
||||
match (old_lines.next(), new_lines.next()) {
|
||||
(None, None) => break,
|
||||
(Some(a), Some(b)) => {
|
||||
count += 1;
|
||||
if old[a.content_end..a.end] != new[b.content_end..b.end] {
|
||||
return Err(PreviewError::LineEndingChanged {
|
||||
path: path.clone(),
|
||||
line: count,
|
||||
});
|
||||
}
|
||||
if old[a.start..a.end] != new[b.start..b.end] {
|
||||
if preview.changed_lines == limits.changed_lines {
|
||||
return Err(PreviewError::TooManyChangedLines {
|
||||
limit: limits.changed_lines,
|
||||
});
|
||||
}
|
||||
preview.changed_lines += 1;
|
||||
changed.push(count - 1);
|
||||
}
|
||||
}
|
||||
_ => return Err(PreviewError::LineCountChanged(path.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// Merge touching context intervals in order, without pairwise diff.
|
||||
// At most 7 indexed lines per changed line are retained; unchanged
|
||||
// lines outside these intervals are scanned but never indexed.
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
for line in changed {
|
||||
let range =
|
||||
line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count);
|
||||
if let Some(previous) = ranges.last_mut()
|
||||
&& range.start <= previous.end
|
||||
{
|
||||
previous.end = previous.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
ranges.push(range);
|
||||
}
|
||||
preview.charge(path.len(), limits)?;
|
||||
let mut file = File {
|
||||
path: path.clone(),
|
||||
hunks: Vec::new(),
|
||||
};
|
||||
let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate();
|
||||
for range in ranges {
|
||||
let mut hunk = Hunk { lines: Vec::new() };
|
||||
for (ordinal, (a, b)) in pairs.by_ref() {
|
||||
if ordinal < range.start {
|
||||
continue;
|
||||
}
|
||||
// Charge exact UTF-8 bytes, including preserved endings and
|
||||
// both copies of shared context, before adding the index.
|
||||
// No render-time omission or shortening grants admission.
|
||||
preview.charge(a.end - a.start, limits)?;
|
||||
preview.charge(b.end - b.start, limits)?;
|
||||
hunk.lines.push(Line {
|
||||
number: ordinal + 1,
|
||||
changed: old[a.start..a.end] != new[b.start..b.end],
|
||||
before: a.start..a.end,
|
||||
after: b.start..b.end,
|
||||
});
|
||||
if ordinal + 1 == range.end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
file.hunks.push(hunk);
|
||||
}
|
||||
preview.files.push(file);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> {
|
||||
let Some(total) = self.display_bytes.checked_add(bytes) else {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
};
|
||||
if total > limits.display_bytes {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
}
|
||||
self.display_bytes = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All admitted files in source path order; suitable for a file picker/search.
|
||||
pub fn files(&self) -> &[File] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
/// Changed source lines, not occurrences, declarations or semantic uses.
|
||||
pub fn changed_line_count(&self) -> usize {
|
||||
self.changed_lines
|
||||
}
|
||||
|
||||
/// Exact displayed source bytes for both sides plus each stored path once.
|
||||
/// Index metadata is separately bounded by 7 lines per changed line.
|
||||
pub fn display_bytes(&self) -> usize {
|
||||
self.display_bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Every hunk is present; the caller may page these indexes, never truncate.
|
||||
pub fn hunks(&self) -> &[Hunk] {
|
||||
&self.hunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
/// The complete ordered line slice supports paging even a large single hunk.
|
||||
pub fn lines(&self) -> &[Line] {
|
||||
&self.lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// One-based source line number, identical on both sides.
|
||||
pub fn number(&self) -> usize {
|
||||
self.number
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// Borrow from the immutable original file; includes its exact line ending.
|
||||
pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> {
|
||||
original.get(self.before.clone())
|
||||
}
|
||||
|
||||
/// Borrow from the immutable candidate file; includes its exact line ending.
|
||||
pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> {
|
||||
candidate.get(self.after.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct RawLine {
|
||||
start: usize,
|
||||
content_end: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
struct Lines<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lines<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
bytes: text.as_bytes(),
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Lines<'_> {
|
||||
type Item = RawLine;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.offset == self.bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let start = self.offset;
|
||||
// The compiler and source navigation count LF, not bare CR, as a line
|
||||
// boundary. Preserve CRLF as an ending and bare CR as source content.
|
||||
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
|
||||
self.offset += 1;
|
||||
}
|
||||
let mut content_end = self.offset;
|
||||
if self.offset < self.bytes.len() {
|
||||
if self.offset > start && self.bytes[self.offset - 1] == b'\r' {
|
||||
content_end -= 1;
|
||||
}
|
||||
self.offset += 1;
|
||||
}
|
||||
Some(RawLine {
|
||||
start,
|
||||
content_end,
|
||||
end: self.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "救援.arcadia".into(),
|
||||
files: [("救援.arcadia".into(), source.into())].into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() {
|
||||
let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200));
|
||||
let pieces: Vec<_> = (0..segment_count(&text))
|
||||
.map(|i| text_segment(&text, i).unwrap())
|
||||
.collect();
|
||||
assert!(pieces.len() > 2);
|
||||
assert!(pieces.iter().all(|part| part.len() <= 255));
|
||||
assert_eq!(pieces.concat(), text);
|
||||
assert_eq!(text_segment(&text, segment_count(&text)), None);
|
||||
assert_eq!(text_segment(&text, usize::MAX), None);
|
||||
assert_eq!(segment_count(""), 1);
|
||||
assert_eq!(text_segment("", 0), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() {
|
||||
let old = bundle("// 救援\r\n@a @a\ncontext\r@a");
|
||||
let new = bundle("// 救援\r\n@long @long\ncontext\r@long");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 2);
|
||||
let file = &preview.files()[0];
|
||||
assert_eq!(file.path(), "救援.arcadia");
|
||||
assert_eq!(file.hunks().len(), 1);
|
||||
let lines = file.hunks()[0].lines();
|
||||
assert_eq!(
|
||||
lines.iter().map(Line::number).collect::<Vec<_>>(),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
lines[0].before_text(&old.files[&old.entry]),
|
||||
Some("// 救援\r\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1].after_text(&new.files[&new.entry]),
|
||||
Some("@long @long\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[2].after_text(&new.files[&new.entry]),
|
||||
Some("context\r@long")
|
||||
);
|
||||
assert!(lines[1].changed() && lines[2].changed());
|
||||
assert!(!lines[0].changed());
|
||||
assert_eq!(
|
||||
preview.display_bytes(),
|
||||
old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_last_lines_form_complete_separate_hunks_and_pages() {
|
||||
let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na");
|
||||
let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
let hunks = preview.files()[0].hunks();
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let page_numbers: Vec<_> = hunks
|
||||
.iter()
|
||||
.flat_map(|h| h.lines().chunks(2))
|
||||
.flat_map(|page| page.iter().map(Line::number))
|
||||
.collect();
|
||||
assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]);
|
||||
assert_eq!(
|
||||
hunks[1].lines()[3].after_text(&new.files[&new.entry]),
|
||||
Some("long")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_changes_merge_and_all_changed_files_are_reachable() {
|
||||
let mut old = bundle("a\n1\n2\n3\n4\n5\na\n");
|
||||
let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n");
|
||||
old.files.insert("other.arcadia".into(), "a a a".into());
|
||||
new.files.insert("other.arcadia".into(), "b b b".into());
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 3);
|
||||
assert_eq!(preview.files().len(), 2);
|
||||
assert_eq!(preview.files()[0].path(), "other.arcadia");
|
||||
assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_empty_sources_have_no_preview() {
|
||||
for source in ["", "a\r\nb\n", "a"] {
|
||||
let original = bundle(source);
|
||||
let preview = Preview::build(&original, &original).unwrap();
|
||||
assert!(preview.is_empty());
|
||||
assert_eq!(preview.changed_line_count(), 0);
|
||||
assert_eq!(preview.display_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_membership_entry_line_count_and_line_ending_changes() {
|
||||
let old = bundle("a\nb");
|
||||
let mut new = old.clone();
|
||||
new.files.insert("extra.arcadia".into(), "".into());
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
let mut new = old.clone();
|
||||
new.entry = "missing.arcadia".into();
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
assert!(matches!(
|
||||
Preview::build(&old, &bundle("a\nb\nc")),
|
||||
Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("a\nb\n"), &bundle("a\n")),
|
||||
Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] {
|
||||
assert_eq!(
|
||||
Preview::build(&bundle(a), &bundle(b)).unwrap_err(),
|
||||
PreviewError::LineEndingChanged {
|
||||
path: old.entry.clone(),
|
||||
line: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() {
|
||||
let old = bundle("a\ncontext\na");
|
||||
let new = bundle("bbb\ncontext\nbbb");
|
||||
let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len();
|
||||
let limits = Limits {
|
||||
files: 1,
|
||||
changed_lines: 2,
|
||||
display_bytes: exact,
|
||||
};
|
||||
assert!(Preview::with_limits(&old, &new, limits).is_ok());
|
||||
assert_eq!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(),
|
||||
PreviewError::TooManyFiles { limit: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyChangedLines { limit: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: exact - 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyDisplayBytes { limit: exact - 1 }
|
||||
);
|
||||
let long = bundle(&"a".repeat(LIMITS.display_bytes));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("b"), &long),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_whole_preview_not_each_file() {
|
||||
let mut old = bundle("a");
|
||||
let mut new = bundle("b");
|
||||
old.files.insert("second.arcadia".into(), "a".into());
|
||||
new.files.insert("second.arcadia".into(), "b".into());
|
||||
let limits = Limits {
|
||||
files: 2,
|
||||
changed_lines: 2,
|
||||
display_bytes: old.entry.len() + "second.arcadia".len() + 4,
|
||||
};
|
||||
let complete = Preview::with_limits(&old, &new, limits).unwrap();
|
||||
assert_eq!(complete.files().len(), 2);
|
||||
assert_eq!(complete.changed_line_count(), 2);
|
||||
assert_eq!(complete.display_bytes(), limits.display_bytes);
|
||||
assert!(matches!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 1, ..limits }),
|
||||
Err(PreviewError::TooManyFiles { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyChangedLines { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: limits.display_bytes - 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,83 @@
|
||||
use iced::{
|
||||
Border, Color, Theme,
|
||||
widget::{button, container},
|
||||
theme::palette::{Extended, Pair},
|
||||
widget::{button, container, text_editor, text_input},
|
||||
};
|
||||
use oa_core::Kind;
|
||||
pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969);
|
||||
pub const INK: Color = Color::from_rgb(0.13, 0.18, 0.25);
|
||||
pub const MUTED: Color = Color::from_rgb(0.43, 0.48, 0.55);
|
||||
pub const TEAL: Color = Color::from_rgb(0.0, 0.48, 0.43);
|
||||
pub const INK: Color = Color::from_rgb8(24, 33, 44);
|
||||
pub const MUTED: Color = Color::from_rgb8(75, 89, 104);
|
||||
pub const TEAL: Color = Color::from_rgb8(0, 102, 92);
|
||||
/// Decorative dividers; essential outlines use BOUNDARY instead.
|
||||
pub const LINE: Color = Color::from_rgb(0.86, 0.89, 0.91);
|
||||
pub const BOUNDARY: Color = Color::from_rgb8(102, 117, 134);
|
||||
const WARNING: Color = Color::from_rgb8(134, 91, 0);
|
||||
const SELECTION: Color = Color::from_rgb8(217, 238, 232);
|
||||
// Ordinary values remain INK; syntax selection has its own surface and palette.
|
||||
const INPUT_SELECTION: Color = Color::from_rgb8(123, 142, 134);
|
||||
const SOURCE_SELECTION: Color = Color::from_rgb8(128, 149, 139);
|
||||
const SOURCE_INK: Color = Color::from_rgb8(33, 37, 41);
|
||||
pub fn theme() -> Theme {
|
||||
Theme::custom(
|
||||
Theme::custom_with_fn(
|
||||
"Arcadia Light",
|
||||
iced::theme::Palette {
|
||||
background: BG,
|
||||
text: INK,
|
||||
primary: TEAL,
|
||||
success: TEAL,
|
||||
warning: Color::from_rgb(0.72, 0.47, 0.10),
|
||||
warning: WARNING,
|
||||
danger: Color::from_rgb(0.78, 0.22, 0.24),
|
||||
},
|
||||
|palette| {
|
||||
let mut extended = Extended::generate(palette);
|
||||
// Pair::new adjusts text toward a 6:1 target. Keep these reviewed
|
||||
// colors exact; resolved style tests enforce each consumer contract.
|
||||
let pair = |color, text| Pair { color, text };
|
||||
extended.background.base = pair(BG, INK);
|
||||
extended.background.weakest = pair(Color::WHITE, INK);
|
||||
extended.background.weaker = pair(BG, INK);
|
||||
// These pairs also supply pick-list/menu and disabled-control fills.
|
||||
extended.background.weak = pair(Color::WHITE, INK);
|
||||
extended.background.neutral = pair(LINE, INK);
|
||||
extended.background.strong = pair(BOUNDARY, Color::WHITE);
|
||||
extended.background.stronger = pair(MUTED, Color::WHITE);
|
||||
extended.background.strongest = pair(MUTED, Color::WHITE);
|
||||
extended.primary.base = pair(TEAL, Color::WHITE);
|
||||
extended.primary.weak = pair(INPUT_SELECTION, INK);
|
||||
extended.primary.strong = pair(Color::from_rgb8(0, 83, 75), Color::WHITE);
|
||||
// Secondary base is both placeholder ink and a button background.
|
||||
extended.secondary.base = pair(MUTED, Color::WHITE);
|
||||
extended.secondary.weak = pair(LINE, INK);
|
||||
extended.secondary.strong = pair(Color::from_rgb8(54, 68, 82), Color::WHITE);
|
||||
extended.success.base = extended.primary.base;
|
||||
extended.success.weak = pair(SELECTION, INK);
|
||||
extended.success.strong = extended.primary.strong;
|
||||
extended.warning.base = pair(WARNING, Color::WHITE);
|
||||
extended.warning.weak = pair(Color::from_rgb8(250, 235, 203), INK);
|
||||
extended.warning.strong = pair(Color::from_rgb8(103, 70, 0), Color::WHITE);
|
||||
extended.danger.base = pair(palette.danger, Color::WHITE);
|
||||
extended.danger.weak = pair(Color::from_rgb8(249, 227, 229), INK);
|
||||
extended.danger.strong = pair(Color::from_rgb8(156, 34, 40), Color::WHITE);
|
||||
extended
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn source_editor(theme: &Theme, status: text_editor::Status) -> text_editor::Style {
|
||||
let mut style = text_editor::default(theme, status);
|
||||
style.background = Color::WHITE.into();
|
||||
style.value = SOURCE_INK;
|
||||
style.placeholder = SOURCE_INK;
|
||||
style.selection = SOURCE_SELECTION;
|
||||
style
|
||||
}
|
||||
/// Inputs without an edit callback still support selection and copy in Iced.
|
||||
pub fn selectable_input(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||||
let mut style = text_input::default(theme, status);
|
||||
if status == text_input::Status::Disabled {
|
||||
style.value = INK;
|
||||
}
|
||||
style
|
||||
}
|
||||
pub fn card(_: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Color::WHITE.into()),
|
||||
@@ -66,11 +123,10 @@ pub fn nav(theme: &Theme, status: button::Status) -> button::Style {
|
||||
}
|
||||
pub fn selected(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let mut s = nav(theme, status);
|
||||
s.background = Some(Color::from_rgb8(218, 238, 235).into());
|
||||
s.text_color = INK;
|
||||
s.background = Some(SELECTION.into());
|
||||
s.border = Border {
|
||||
color: Color::from_rgb8(151, 203, 195),
|
||||
width: 1.,
|
||||
color: TEAL,
|
||||
width: 2.,
|
||||
radius: 7.into(),
|
||||
};
|
||||
s
|
||||
@@ -78,10 +134,10 @@ pub fn selected(theme: &Theme, status: button::Status) -> button::Style {
|
||||
pub fn kind_color(kind: Kind) -> Color {
|
||||
match kind {
|
||||
Kind::Entity | Kind::System => Color::from_rgb(0.16, 0.40, 0.71),
|
||||
Kind::Actor | Kind::SystemActor | Kind::Role => Color::from_rgb(0.58, 0.35, 0.70),
|
||||
Kind::Actor | Kind::SystemActor | Kind::Role => Color::from_rgb8(117, 68, 153),
|
||||
Kind::Activity | Kind::Function => TEAL,
|
||||
Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => {
|
||||
Color::from_rgb(0.72, 0.47, 0.10)
|
||||
WARNING
|
||||
}
|
||||
Kind::Process
|
||||
| Kind::FunctionalChain
|
||||
@@ -94,6 +150,9 @@ pub fn kind_color(kind: Kind) -> Color {
|
||||
| Kind::Mode
|
||||
| Kind::Initial
|
||||
| Kind::Final => Color::from_rgb(0.38, 0.39, 0.68),
|
||||
_ => Color::from_rgb(0.25, 0.48, 0.56),
|
||||
_ => Color::from_rgb8(50, 107, 128),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
use super::*;
|
||||
use iced::{
|
||||
Background,
|
||||
widget::{checkbox, overlay::menu, pick_list, text_input},
|
||||
};
|
||||
use oa_core::language::TokenClass;
|
||||
|
||||
mod readonly;
|
||||
|
||||
fn luminance(color: Color) -> f64 {
|
||||
[color.r, color.g, color.b]
|
||||
.into_iter()
|
||||
.zip([0.2126, 0.7152, 0.0722])
|
||||
.map(|(channel, weight)| {
|
||||
let channel = f64::from(channel);
|
||||
weight
|
||||
* if channel <= 0.04045 {
|
||||
channel / 12.92
|
||||
} else {
|
||||
((channel + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn contrast(first: Color, second: Color) -> f64 {
|
||||
assert_eq!(first.a, 1.0, "opaque color contract: {first:?}");
|
||||
assert_eq!(second.a, 1.0, "opaque color contract: {second:?}");
|
||||
let first = luminance(first);
|
||||
let second = luminance(second);
|
||||
(first.max(second) + 0.05) / (first.min(second) + 0.05)
|
||||
}
|
||||
|
||||
fn require_contrast(label: &str, foreground: Color, background: Color, minimum: f64) {
|
||||
let measured = contrast(foreground, background);
|
||||
assert!(
|
||||
measured >= minimum,
|
||||
"{label}: {foreground:?} on {background:?} = {measured:.6}, required {minimum}"
|
||||
);
|
||||
}
|
||||
|
||||
fn solid(background: Background) -> Color {
|
||||
match background {
|
||||
Background::Color(color) => color,
|
||||
Background::Gradient(_) => panic!("gradient needs a separate contrast evaluator"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_text_and_essential_boundaries_have_contrast_on_canvas_surfaces() {
|
||||
for surface in [
|
||||
Color::WHITE,
|
||||
BG,
|
||||
Color::from_rgb8(246, 248, 251),
|
||||
Color::from_rgb8(243, 248, 248),
|
||||
Color::from_rgb8(234, 241, 248),
|
||||
] {
|
||||
require_contrast("primary text", INK, surface, 4.5);
|
||||
require_contrast("secondary text", MUTED, surface, 4.5);
|
||||
require_contrast("essential outline", BOUNDARY, surface, 3.0);
|
||||
}
|
||||
for kind in Kind::ALL {
|
||||
let color = kind_color(kind);
|
||||
// The architecture painter uses this six-percent encoded-channel tint.
|
||||
let tint = Color::from_rgb(
|
||||
0.94 + 0.06 * color.r,
|
||||
0.94 + 0.06 * color.g,
|
||||
0.94 + 0.06 * color.b,
|
||||
);
|
||||
require_contrast(&format!("{kind:?} header"), color, tint, 4.5);
|
||||
}
|
||||
// Negative controls: do not accidentally treat the decorative divider or
|
||||
// the old secondary-text palette as adequate for these semantic roles.
|
||||
assert!(contrast(LINE, Color::from_rgb8(243, 248, 248)) < 3.0);
|
||||
assert!(contrast(Color::from_rgb(0.43, 0.48, 0.55), BG) < 4.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_editor_syntax_and_fallback_remain_readable_during_selection() {
|
||||
let theme = theme();
|
||||
for status in [
|
||||
text_editor::Status::Active,
|
||||
text_editor::Status::Hovered,
|
||||
text_editor::Status::Focused { is_hovered: false },
|
||||
text_editor::Status::Focused { is_hovered: true },
|
||||
text_editor::Status::Disabled,
|
||||
] {
|
||||
let style = source_editor(&theme, status);
|
||||
let surface = solid(style.background);
|
||||
assert_eq!(surface, Color::WHITE);
|
||||
require_contrast("source selected range", style.selection, surface, 3.0);
|
||||
require_contrast("source boundary", style.border.color, surface, 3.0);
|
||||
require_contrast("source placeholder", style.placeholder, surface, 4.5);
|
||||
for foreground in [style.value, style.placeholder] {
|
||||
require_contrast("source fallback", foreground, surface, 4.5);
|
||||
require_contrast("selected fallback", foreground, style.selection, 4.5);
|
||||
}
|
||||
for class in [
|
||||
TokenClass::Keyword,
|
||||
TokenClass::Identifier,
|
||||
TokenClass::String,
|
||||
TokenClass::Number,
|
||||
TokenClass::Comment,
|
||||
TokenClass::Error,
|
||||
TokenClass::Punctuation,
|
||||
TokenClass::Whitespace,
|
||||
] {
|
||||
let format = crate::highlight::format(&class, &theme);
|
||||
assert!(format.font.is_none(), "palette must preserve font metrics");
|
||||
let foreground = format.color.expect("syntax class has explicit ink");
|
||||
require_contrast(&format!("{class:?} ordinary"), foreground, surface, 4.5);
|
||||
require_contrast(
|
||||
&format!("{class:?} selected"),
|
||||
foreground,
|
||||
style.selection,
|
||||
4.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(contrast(Color::from_rgb8(32, 44, 58), SOURCE_SELECTION) < 4.5);
|
||||
assert!(contrast(Color::from_rgb8(111, 121, 132), SOURCE_SELECTION) < 4.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_input_styles_keep_ordinary_values_and_visible_selection() {
|
||||
let theme = theme();
|
||||
for status in [
|
||||
text_input::Status::Active,
|
||||
text_input::Status::Hovered,
|
||||
text_input::Status::Focused { is_hovered: false },
|
||||
text_input::Status::Focused { is_hovered: true },
|
||||
] {
|
||||
let style = text_input::default(&theme, status);
|
||||
let surface = solid(style.background);
|
||||
assert_eq!(surface, BG, "preserve ordinary input surface");
|
||||
assert_eq!(style.value, INK, "do not apply source syntax ink to inputs");
|
||||
assert_ne!(style.selection, SOURCE_SELECTION);
|
||||
require_contrast("input value", style.value, surface, 4.5);
|
||||
require_contrast("input placeholder", style.placeholder, surface, 4.5);
|
||||
require_contrast("input icon", style.icon, surface, 3.0);
|
||||
require_contrast("input boundary", style.border.color, surface, 3.0);
|
||||
require_contrast(
|
||||
"input boundary exterior",
|
||||
style.border.color,
|
||||
Color::WHITE,
|
||||
3.0,
|
||||
);
|
||||
require_contrast("input selection", style.selection, surface, 3.0);
|
||||
require_contrast("input selected value", style.value, style.selection, 4.5);
|
||||
}
|
||||
let disabled = text_input::default(&theme, text_input::Status::Disabled);
|
||||
let surface = solid(disabled.background);
|
||||
require_contrast("disabled input value", disabled.value, surface, 4.5);
|
||||
require_contrast(
|
||||
"disabled input placeholder",
|
||||
disabled.placeholder,
|
||||
surface,
|
||||
4.5,
|
||||
);
|
||||
// Iced still permits selection/copy without an edit callback. Its default
|
||||
// disabled pair is a negative control; conditional Hosted inputs use the
|
||||
// selectable_input adapter below instead of inheriting this unreadable pair.
|
||||
assert!(contrast(disabled.value, disabled.selection) < 4.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_input_styles_preserve_readonly_selected_value_contrast() {
|
||||
let theme = theme();
|
||||
for status in [
|
||||
text_input::Status::Active,
|
||||
text_input::Status::Hovered,
|
||||
text_input::Status::Focused { is_hovered: false },
|
||||
text_input::Status::Focused { is_hovered: true },
|
||||
text_input::Status::Disabled,
|
||||
] {
|
||||
let original = text_input::default(&theme, status);
|
||||
let style = selectable_input(&theme, status);
|
||||
assert_eq!(style.background, original.background);
|
||||
assert_eq!(style.border, original.border);
|
||||
assert_eq!(style.placeholder, original.placeholder);
|
||||
assert_eq!(style.value, INK);
|
||||
require_contrast(
|
||||
"conditional input value",
|
||||
style.value,
|
||||
solid(style.background),
|
||||
4.5,
|
||||
);
|
||||
require_contrast("readonly selected value", style.value, style.selection, 4.5);
|
||||
require_contrast(
|
||||
"readonly range",
|
||||
style.selection,
|
||||
solid(style.background),
|
||||
3.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_editable_editor_styles_keep_their_own_selected_value_readable() {
|
||||
let theme = theme();
|
||||
for status in [
|
||||
text_editor::Status::Active,
|
||||
text_editor::Status::Hovered,
|
||||
text_editor::Status::Focused { is_hovered: false },
|
||||
text_editor::Status::Focused { is_hovered: true },
|
||||
] {
|
||||
let style = text_editor::default(&theme, status);
|
||||
assert_eq!(style.value, INK);
|
||||
require_contrast(
|
||||
"ordinary editor value",
|
||||
style.value,
|
||||
solid(style.background),
|
||||
4.5,
|
||||
);
|
||||
require_contrast(
|
||||
"ordinary editor selected value",
|
||||
style.value,
|
||||
style.selection,
|
||||
4.5,
|
||||
);
|
||||
require_contrast(
|
||||
"ordinary editor range",
|
||||
style.selection,
|
||||
solid(style.background),
|
||||
3.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_pick_list_and_menu_styles_cover_open_and_selected_states() {
|
||||
let theme = theme();
|
||||
for status in [
|
||||
pick_list::Status::Active,
|
||||
pick_list::Status::Hovered,
|
||||
pick_list::Status::Opened { is_hovered: false },
|
||||
pick_list::Status::Opened { is_hovered: true },
|
||||
] {
|
||||
let style = pick_list::default(&theme, status);
|
||||
let surface = solid(style.background);
|
||||
require_contrast("pick-list value", style.text_color, surface, 4.5);
|
||||
require_contrast(
|
||||
"pick-list placeholder",
|
||||
style.placeholder_color,
|
||||
surface,
|
||||
4.5,
|
||||
);
|
||||
require_contrast("pick-list handle", style.handle_color, surface, 3.0);
|
||||
require_contrast("pick-list boundary", style.border.color, surface, 3.0);
|
||||
require_contrast("pick-list exterior", style.border.color, BG, 3.0);
|
||||
}
|
||||
let style = menu::default(&theme);
|
||||
let surface = solid(style.background);
|
||||
let selection = solid(style.selected_background);
|
||||
require_contrast("menu text", style.text_color, surface, 4.5);
|
||||
require_contrast("menu boundary", style.border.color, surface, 3.0);
|
||||
require_contrast("menu selected row", selection, surface, 3.0);
|
||||
require_contrast(
|
||||
"menu selected text",
|
||||
style.selected_text_color,
|
||||
selection,
|
||||
4.5,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_checkbox_styles_keep_checks_and_outlines_distinct() {
|
||||
let theme = theme();
|
||||
for checked in [false, true] {
|
||||
for status in [
|
||||
checkbox::Status::Active {
|
||||
is_checked: checked,
|
||||
},
|
||||
checkbox::Status::Hovered {
|
||||
is_checked: checked,
|
||||
},
|
||||
] {
|
||||
let style = checkbox::primary(&theme, status);
|
||||
let surface = solid(style.background);
|
||||
require_contrast("checkbox boundary exterior", style.border.color, BG, 3.0);
|
||||
require_contrast("checkbox label", style.text_color.unwrap_or(INK), BG, 4.5);
|
||||
if checked {
|
||||
require_contrast("checkbox check", style.icon_color, surface, 3.0);
|
||||
} else {
|
||||
require_contrast(
|
||||
"unchecked checkbox boundary",
|
||||
style.border.color,
|
||||
surface,
|
||||
3.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let disabled = checkbox::primary(&theme, checkbox::Status::Disabled { is_checked: true });
|
||||
require_contrast(
|
||||
"disabled check",
|
||||
disabled.icon_color,
|
||||
solid(disabled.background),
|
||||
3.0,
|
||||
);
|
||||
// The disabled unchecked outline is intentionally exempt from the active
|
||||
// control benchmark. It remains an inactive widget, not a missing checked cue.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_pair_couplings_cover_hover_and_disabled_states() {
|
||||
let theme = theme();
|
||||
for (name, resolve) in [
|
||||
(
|
||||
"primary",
|
||||
button::primary as fn(&Theme, button::Status) -> button::Style,
|
||||
),
|
||||
("secondary", button::secondary),
|
||||
("success", button::success),
|
||||
("warning", button::warning),
|
||||
("danger", button::danger),
|
||||
] {
|
||||
for status in [
|
||||
button::Status::Active,
|
||||
button::Status::Hovered,
|
||||
button::Status::Pressed,
|
||||
] {
|
||||
let style = resolve(&theme, status);
|
||||
let surface = solid(style.background.expect("filled button"));
|
||||
require_contrast(name, style.text_color, surface, 4.5);
|
||||
require_contrast("button silhouette", surface, BG, 3.0);
|
||||
}
|
||||
let disabled = resolve(&theme, button::Status::Disabled);
|
||||
// Iced composites disabled colors with alpha; do not feed them into an
|
||||
// opaque-pair contrast test or claim active-state contrast compliance.
|
||||
assert!(disabled.text_color.a > 0.0 && disabled.text_color.a < 1.0);
|
||||
let background = solid(disabled.background.expect("disabled filled button"));
|
||||
assert!(background.a > 0.0 && background.a < 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_navigation_adds_a_visible_outline_and_preserves_disabled_ink() {
|
||||
let theme = theme();
|
||||
for status in [
|
||||
button::Status::Active,
|
||||
button::Status::Hovered,
|
||||
button::Status::Pressed,
|
||||
] {
|
||||
let ordinary = nav(&theme, status);
|
||||
let chosen = selected(&theme, status);
|
||||
let surface = solid(chosen.background.expect("selected navigation fill"));
|
||||
assert!(chosen.border.width >= 2.0 && chosen.border.width > ordinary.border.width);
|
||||
require_contrast("selected navigation text", chosen.text_color, surface, 4.5);
|
||||
require_contrast(
|
||||
"selected navigation interior",
|
||||
chosen.border.color,
|
||||
surface,
|
||||
3.0,
|
||||
);
|
||||
require_contrast(
|
||||
"selected navigation exterior",
|
||||
chosen.border.color,
|
||||
Color::WHITE,
|
||||
3.0,
|
||||
);
|
||||
}
|
||||
let disabled = selected(&theme, button::Status::Disabled);
|
||||
assert!(disabled.text_color.a > 0.0 && disabled.text_color.a < 1.0);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use super::*;
|
||||
use iced::advanced::renderer::Headless;
|
||||
use iced::{Element, Event, Point, Size, keyboard, mouse};
|
||||
use iced_runtime::user_interface::{Cache, UserInterface};
|
||||
use std::cell::Cell;
|
||||
|
||||
const VALUE: &str = "Control portée alpha";
|
||||
const SUBMIT: &str = "submit query";
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
fn input(readonly: bool, observed: &Cell<Option<text_input::Status>>) -> Element<'_, String> {
|
||||
// This is the same conditional-callback and style path as Hosted fields.
|
||||
// No synthetic cursor/selection state is injected into the widget tree.
|
||||
iced::widget::text_input("Search model names", VALUE)
|
||||
.id("readonly-query")
|
||||
.padding(10)
|
||||
.size(18)
|
||||
.on_input_maybe((!readonly).then_some(std::convert::identity::<String>))
|
||||
.on_submit(SUBMIT.to_owned())
|
||||
.style(move |theme, status| {
|
||||
observed.set(Some(status));
|
||||
selectable_input(theme, status)
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn key(
|
||||
key: keyboard::Key,
|
||||
code: keyboard::key::Code,
|
||||
modifiers: keyboard::Modifiers,
|
||||
text: Option<&str>,
|
||||
) -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
modified_key: key.clone(),
|
||||
key,
|
||||
physical_key: keyboard::key::Physical::Code(code),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: text.map(Into::into),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn modifiers(modifiers: keyboard::Modifiers) -> Event {
|
||||
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers))
|
||||
}
|
||||
|
||||
fn near(pixel: &[u8], color: Color, tolerance: u8) -> bool {
|
||||
pixel[..3]
|
||||
.iter()
|
||||
.zip([color.r, color.g, color.b])
|
||||
.all(|(byte, channel)| byte.abs_diff((channel * 255.0).round() as u8) <= tolerance)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_readonly_input_keeps_selection_copy_and_readable_paint() {
|
||||
let mut renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 18.into(), None)
|
||||
.await
|
||||
.expect("mounted input test requires the supported WGPU headless renderer");
|
||||
let theme = theme();
|
||||
let size = Size::new(480.0, 64.0);
|
||||
let cursor = mouse::Cursor::Available(Point::new(20.0, 18.0));
|
||||
let command = keyboard::Modifiers::COMMAND;
|
||||
let no_modifiers = keyboard::Modifiers::empty();
|
||||
|
||||
for submit_then_readonly in [false, true] {
|
||||
let observed = Cell::new(None);
|
||||
let mut clipboard = Clipboard::default();
|
||||
let mut messages = Vec::new();
|
||||
let mut ui = UserInterface::build(
|
||||
input(!submit_then_readonly, &observed),
|
||||
size,
|
||||
Cache::new(),
|
||||
&mut renderer,
|
||||
);
|
||||
let _ = ui.update(
|
||||
&[
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
modifiers(command),
|
||||
key(
|
||||
keyboard::Key::Character("a".into()),
|
||||
keyboard::key::Code::KeyA,
|
||||
command,
|
||||
None,
|
||||
),
|
||||
modifiers(no_modifiers),
|
||||
],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut clipboard,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(messages.is_empty(), "selection must not mutate the field");
|
||||
|
||||
if submit_then_readonly {
|
||||
let _ = ui.update(
|
||||
&[key(
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
keyboard::key::Code::Enter,
|
||||
no_modifiers,
|
||||
None,
|
||||
)],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut clipboard,
|
||||
&mut messages,
|
||||
);
|
||||
assert_eq!(messages, [SUBMIT.to_owned()]);
|
||||
messages.clear();
|
||||
// Model the Hosted submit -> busy rebuild while retaining Iced's
|
||||
// actual focus/selection cache, value and widget identity.
|
||||
let cache = ui.into_cache();
|
||||
ui = UserInterface::build(input(true, &observed), size, cache, &mut renderer);
|
||||
}
|
||||
|
||||
let copy = [
|
||||
modifiers(command),
|
||||
key(
|
||||
keyboard::Key::Character("c".into()),
|
||||
keyboard::key::Code::KeyC,
|
||||
command,
|
||||
None,
|
||||
),
|
||||
modifiers(no_modifiers),
|
||||
];
|
||||
let _ = ui.update(©, cursor, &mut renderer, &mut clipboard, &mut messages);
|
||||
assert_eq!(clipboard.0.as_deref(), Some(VALUE));
|
||||
assert!(messages.is_empty(), "copy must not publish an edit");
|
||||
|
||||
let _ = ui.update(
|
||||
&[
|
||||
key(
|
||||
keyboard::Key::Character("z".into()),
|
||||
keyboard::key::Code::KeyZ,
|
||||
no_modifiers,
|
||||
Some("z"),
|
||||
),
|
||||
key(
|
||||
keyboard::Key::Named(keyboard::key::Named::Backspace),
|
||||
keyboard::key::Code::Backspace,
|
||||
no_modifiers,
|
||||
None,
|
||||
),
|
||||
],
|
||||
cursor,
|
||||
&mut renderer,
|
||||
&mut clipboard,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"no callback means no edit, even while selected"
|
||||
);
|
||||
clipboard.0 = None;
|
||||
let _ = ui.update(©, cursor, &mut renderer, &mut clipboard, &mut messages);
|
||||
assert_eq!(
|
||||
clipboard.0.as_deref(),
|
||||
Some(VALUE),
|
||||
"readonly input must retain its selected value"
|
||||
);
|
||||
|
||||
ui.draw(
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&iced::advanced::renderer::Style { text_color: INK },
|
||||
cursor,
|
||||
);
|
||||
assert_eq!(observed.get(), Some(text_input::Status::Disabled));
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(480, 64), 1.0, BG);
|
||||
let style = selectable_input(&theme, text_input::Status::Disabled);
|
||||
let selection_pixels: Vec<_> = pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, pixel)| near(*pixel, style.selection, 3))
|
||||
.map(|(index, _)| (index % 480, index / 480))
|
||||
.collect();
|
||||
assert!(
|
||||
selection_pixels.len() > 100,
|
||||
"actual readonly range must be painted"
|
||||
);
|
||||
let left = selection_pixels.iter().map(|p| p.0).min().unwrap();
|
||||
let right = selection_pixels.iter().map(|p| p.0).max().unwrap();
|
||||
let top = selection_pixels.iter().map(|p| p.1).min().unwrap();
|
||||
let bottom = selection_pixels.iter().map(|p| p.1).max().unwrap();
|
||||
let glyph_pixels = pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, pixel)| {
|
||||
let (x, y) = (index % 480, index / 480);
|
||||
(left..=right).contains(&x)
|
||||
&& (top..=bottom).contains(&y)
|
||||
&& near(*pixel, style.value, 12)
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
glyph_pixels > 10,
|
||||
"selected glyphs must render with the dark value ink"
|
||||
);
|
||||
// Check specified colors separately from antialiased glyph-edge pixels.
|
||||
require_contrast(
|
||||
"painted readonly selected value",
|
||||
style.value,
|
||||
style.selection,
|
||||
4.5,
|
||||
);
|
||||
require_contrast(
|
||||
"painted readonly selection",
|
||||
style.selection,
|
||||
solid(style.background),
|
||||
3.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1690,388 +1690,3 @@ fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_work_preserves_allocated_context_and_atomic_history() {
|
||||
use oa_core::projection::project_view;
|
||||
for (layer, performer_kind, work_kind) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::Function),
|
||||
] {
|
||||
for folded in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(performer_kind, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(work_kind, layer, "Parent work", None)
|
||||
.unwrap();
|
||||
let allocation = p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Engineering assignment",
|
||||
performer,
|
||||
work,
|
||||
);
|
||||
p.relations.get_mut(&allocation).unwrap().description =
|
||||
"Retain assignment metadata".into();
|
||||
let original = p.relations[&allocation].clone();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let performer_node = p.place(diagram, performer, Position { x: 60., y: 60. });
|
||||
let work_node = p.place(diagram, work, Position { x: 84., y: 124. });
|
||||
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
for node in [performer_node, work_node] {
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.collapsed = folded;
|
||||
}
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Layer(layer));
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
let mut first = None;
|
||||
for step in 0..3 {
|
||||
let parent = if step == 2 { first.unwrap() } else { work };
|
||||
send(&mut app, Message::Select(parent));
|
||||
let before = app.project().clone();
|
||||
let before_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::CreateContextElement(work_kind, true));
|
||||
let created = app.selected;
|
||||
assert!(
|
||||
!before.elements.contains_key(&created),
|
||||
"{layer:?} step {step}: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.project().elements.len(), before.elements.len() + 1);
|
||||
assert_eq!(app.project().elements[&created].parent, Some(parent));
|
||||
for (id, element) in &before.elements {
|
||||
assert_eq!(&app.project().elements[id], element);
|
||||
}
|
||||
assert!(
|
||||
!oa_core::validation::validate(app.project())
|
||||
.iter()
|
||||
.any(|i| i.severity == oa_core::validation::Severity::Error)
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::allocation_summary(app.project(), work),
|
||||
Some(performer)
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::effective_performer(app.project(), created),
|
||||
Some(performer)
|
||||
);
|
||||
assert!(
|
||||
!app.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
|
||||
);
|
||||
if step != 1 {
|
||||
let mut expected = original.clone();
|
||||
if let RelationBody::Allocation { target, .. } = &mut expected.body {
|
||||
*target = created;
|
||||
}
|
||||
assert_eq!(app.project().relations[&allocation], expected);
|
||||
} else {
|
||||
assert_eq!(
|
||||
app.project().relations[&allocation],
|
||||
before.relations[&allocation]
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.project().relations.len(),
|
||||
before.relations.len() + usize::from(step == 1)
|
||||
);
|
||||
let view = project_view(app.project(), diagram);
|
||||
let parent_node = view.nodes.iter().find(|n| n.element == parent).unwrap();
|
||||
let child_node = view.nodes.iter().find(|n| n.element == created).unwrap();
|
||||
let root_node = view.nodes.iter().find(|n| n.element == work).unwrap();
|
||||
assert_eq!(root_node.visual_parent, Some(performer_node));
|
||||
assert_eq!(child_node.visual_parent, Some(parent_node.id));
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&performer_node].collapsed);
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&parent_node.id].collapsed);
|
||||
assert!(child_node.position.x >= parent_node.position.x);
|
||||
assert!(child_node.position.y >= parent_node.position.y + 64.);
|
||||
assert!(
|
||||
child_node.position.x + child_node.size.width
|
||||
<= parent_node.position.x + parent_node.size.width
|
||||
);
|
||||
assert!(
|
||||
child_node.position.y + child_node.size.height
|
||||
<= parent_node.position.y + parent_node.size.height
|
||||
);
|
||||
for sibling in view
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.visual_parent == Some(parent_node.id) && n.id != child_node.id)
|
||||
{
|
||||
assert!(sibling.position.y + sibling.size.height <= child_node.position.y);
|
||||
}
|
||||
let after = app.project().clone();
|
||||
let after_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&before,
|
||||
"creation, allocation and reveal/reflow undo together"
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before_source);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after);
|
||||
assert_eq!(app.document.bundle(), &after_source);
|
||||
if step == 0 {
|
||||
first = Some(created);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_work_does_not_invent_unallocated_or_mixed_performers() {
|
||||
for (layer, performer_kind, other_kind, work_kind) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::SystemActor, Kind::Function),
|
||||
] {
|
||||
// Root leaf, partly unallocated composite, and mixed-performer composite.
|
||||
for context in 0..3 {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(performer_kind, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(other_kind, layer, "Other performer", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(work_kind, layer, "Parent work", None)
|
||||
.unwrap();
|
||||
let mut placed = vec![performer, other, work];
|
||||
if context != 0 {
|
||||
let first = p
|
||||
.add_element(work_kind, layer, "First leaf", Some(work))
|
||||
.unwrap();
|
||||
let second = p
|
||||
.add_element(work_kind, layer, "Second leaf", Some(work))
|
||||
.unwrap();
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"First assignment",
|
||||
performer,
|
||||
first,
|
||||
);
|
||||
if context == 2 {
|
||||
p.relate(RelationKind::Allocation, "Other assignment", other, second);
|
||||
}
|
||||
placed.extend([first, second]);
|
||||
}
|
||||
assert_eq!(oa_core::validation::allocation_summary(&p, work), None);
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
for id in placed {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Layer(layer));
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Message::Select(work));
|
||||
let before = app.project().clone();
|
||||
let before_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::CreateContextElement(work_kind, true));
|
||||
let child = app.selected;
|
||||
assert!(!before.elements.contains_key(&child), "{}", app.status);
|
||||
assert_eq!(app.project().elements[&child].parent, Some(work));
|
||||
assert_eq!(app.project().relations, before.relations);
|
||||
assert_eq!(
|
||||
oa_core::validation::effective_performer(app.project(), child),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::allocation_summary(app.project(), work),
|
||||
None
|
||||
);
|
||||
let after = app.project().clone();
|
||||
let after_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before);
|
||||
assert_eq!(app.document.bundle(), &before_source);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after);
|
||||
assert_eq!(app.document.bundle(), &after_source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_work_transfers_leaf_roles_without_fabricating_responsibilities() {
|
||||
// Direct allocation + matching roles, uniquely realized roles only,
|
||||
// unresolved roles, and roles realized by different performers.
|
||||
for context in 0..4 {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Other performer", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Responsible work", None)
|
||||
.unwrap();
|
||||
let mut responsibilities = Vec::new();
|
||||
if context == 0 {
|
||||
responsibilities.push(p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Direct responsibility",
|
||||
performer,
|
||||
work,
|
||||
));
|
||||
}
|
||||
for index in 0..2 {
|
||||
let role = p
|
||||
.add_element(Kind::Role, Layer::Oa, &format!("Role {index}"), None)
|
||||
.unwrap();
|
||||
responsibilities.push(p.relate(
|
||||
RelationKind::RoleAssignment,
|
||||
&format!("Assigned role {index}"),
|
||||
role,
|
||||
work,
|
||||
));
|
||||
if context != 2 {
|
||||
let source = if context == 3 && index == 1 {
|
||||
other
|
||||
} else {
|
||||
performer
|
||||
};
|
||||
p.relate(RelationKind::RoleRealization, "Realizes role", source, role);
|
||||
}
|
||||
}
|
||||
for (index, id) in responsibilities.iter().enumerate() {
|
||||
let relation = p.relations.get_mut(id).unwrap();
|
||||
relation.description =
|
||||
format!("Responsibility {index}: preserve engineering rationale");
|
||||
let link = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
link,
|
||||
ArtifactLink {
|
||||
id: link,
|
||||
alias: format!("responsibility_evidence_{index}"),
|
||||
name: format!("Responsibility evidence {index}"),
|
||||
description: "Preserve the reference to this exact relation".into(),
|
||||
owner: *id,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: format!("https://example.invalid/responsibility/{index}"),
|
||||
revision: Some("review-7".into()),
|
||||
fragment: Some("assignment".into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
let original_relations = p.relations.clone();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
p.place(diagram, performer, Position { x: 60., y: 60. });
|
||||
p.place(diagram, work, Position { x: 84., y: 124. });
|
||||
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let expected_performer = (context < 2).then_some(performer);
|
||||
assert_eq!(
|
||||
oa_core::validation::allocation_summary(&p, work),
|
||||
expected_performer
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
let mut first = None;
|
||||
for step in 0..2 {
|
||||
send(&mut app, Message::Select(work));
|
||||
let before = app.project().clone();
|
||||
let before_source = app.document.bundle().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Message::CreateContextElement(Kind::Activity, true),
|
||||
);
|
||||
let child = app.selected;
|
||||
assert!(
|
||||
!before.elements.contains_key(&child),
|
||||
"context {context}, step {step}: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.project().elements[&child].parent, Some(work));
|
||||
if step == 0 {
|
||||
first = Some(child);
|
||||
}
|
||||
for (id, original) in &original_relations {
|
||||
let mut expected = original.clone();
|
||||
if responsibilities.contains(id) {
|
||||
match &mut expected.body {
|
||||
RelationBody::Allocation { target, .. }
|
||||
| RelationBody::RoleAssignment { target, .. } => *target = first.unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert_eq!(app.project().relations[id], expected);
|
||||
}
|
||||
assert_eq!(app.project().artifact_links, before.artifact_links);
|
||||
assert!(!app.project().artifact_links.is_empty());
|
||||
let added = usize::from(step == 1 && expected_performer.is_some());
|
||||
assert_eq!(
|
||||
app.project().relations.len(),
|
||||
before.relations.len() + added
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::effective_performer(app.project(), child),
|
||||
expected_performer
|
||||
);
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == child)
|
||||
.count(),
|
||||
if step == 0 { 2 } else { 0 }
|
||||
);
|
||||
if context != 0 && step == 0 {
|
||||
assert!(
|
||||
!app.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!oa_core::validation::validate(app.project())
|
||||
.iter()
|
||||
.any(|i| i.severity == oa_core::validation::Severity::Error)
|
||||
);
|
||||
let after = app.project().clone();
|
||||
let after_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before);
|
||||
assert_eq!(app.document.bundle(), &before_source);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after);
|
||||
assert_eq!(app.document.bundle(), &after_source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user