Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu d82568003e Sync agent instructions from main 2026-09-13 11:39:28 +03:00
Codex-ytr83r a49b0ec7ac test: satisfy strict navigation usage lints 2026-09-12 19:14:44 -07:00
Codex-ytr83r 10bd711972 Correct compiled snapshot access in navigation rename regression 2026-09-12 18:26:19 -07:00
Codex-ytr83r 208268c8aa Combine Text alias rename with guarded diagram navigation 2026-09-12 17:27:08 -07:00
Codex-ytr83r 4db1ef543c Preserve original Windows test evidence bytes 2026-09-12 16:09:18 -07:00
Codex-ytr83r 0eca66f669 Explain raw test log whitespace preservation 2026-09-12 15:58:25 -07:00
Codex-ytr83r 2488ee5955 Record passing Windows diagram navigation tests 2026-09-12 15:50:15 -07:00
Codex-ytr83r 46b4f3c0d0 Preserve exact diagram usage identity and separate visual focus 2026-09-12 15:10:33 -07:00
Codex-ytr83r f23615ffeb Record layout policy experiment and reproduced history falsifier 2026-09-12 07:27:06 -07:00
Codex-ytr83r fdf756e6a4 Publish in-progress navigation source and independent canvas reviews 2026-09-12 06:58:08 -07:00
Codex-ytr83r 7aa007b077 Publish verified canvas and repair Windows prerelease checkpoint 2026-09-12 05:06:18 -07:00
Codex-ytr83r 0c53c21030 Record verified Windows canvas and repair package acceptance 2026-09-12 04:59:48 -07:00
Codex-ytr83r 4dd0f60c75 Record complete combined Windows gates and bounded native camera checks 2026-09-12 04:26:03 -07:00
Codex-ytr83r 60f714853f Merge commit '51846f5a2b44ae31f8e8f1127fc803f2f430fbe7' into codex/canvas-union-ytr83r-20260911 2026-09-12 04:06:16 -07:00
Codex-ytr83r 5511755a5e Preserve closed native and Unicode verification with repair integration review 2026-09-12 04:02:45 -07:00
sorokin.oleg.edu 51846f5a2b Sync agent instructions from main 2026-09-12 13:43:20 +03:00
sorokin.oleg.edu 436ea4b29a Sync agent instructions from main 2026-09-12 13:40:03 +03:00
sorokin.oleg.edu 46c8783c72 Sync agent instructions from main 2026-09-12 13:15:35 +03:00
647 changed files with 111383 additions and 190 deletions
+14
View File
@@ -70,3 +70,17 @@ 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
+14 -6
View File
@@ -35,20 +35,28 @@ Use Gitea for live coordination and the relevant code version for implementation
## Coordination and integration
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, branch/worktree, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and uncommitted or unpublished work; make required artifacts accessible to other sessions. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. Do not overwrite others' work or force-push shared history to resolve coordination problems.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
## Delegation and reasoning
Use GPT-6 Astra exclusively. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Use the highest available reasoning effort for orchestration and foundational semantic, methodological, formal, or cross-domain decisions. For subagents, use extra-high for difficult design, adversarial review, or unusually hard implementation; high for normal implementation, targeted research, correctness review, debugging, and runtime evaluation; medium for routine exploration, mechanical changes, builds, and tests; and lower for trivial work. Allocate by difficulty, not length, using supported controls; do not claim an unapplied change or alter global configuration merely to enforce these preferences.
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
## Memory and maintenance
+160 -27
View File
@@ -142,12 +142,87 @@ pub struct Analysis {
pub declarations: Vec<DeclarationSpan>,
}
/// Check the decoded spelling required by declaration-header UUID clauses.
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
pub fn has_uuid_header_shape(decoded: &str) -> bool {
decoded.len() == 36
&& [8, 13, 18, 23]
.into_iter()
.all(|index| decoded.as_bytes()[index] == b'-')
}
/// Budgets for one original source buffer. Policy is caller-owned; these
/// limits do not change the grammar or perform model admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimits {
pub max_source_bytes: usize,
pub max_tokens: usize,
pub max_declarations: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutlineResource {
SourceBytes,
Tokens,
Declarations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimit {
pub resource: OutlineResource,
pub limit: usize,
pub observed_at_least: usize,
/// Original byte offset where admission stopped; never a rewritten offset.
pub offset: usize,
}
/// Roles discovered by the existing tolerant declaration walk. Keeping the
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutlineDeclaration {
pub declaration: DeclarationSpan,
pub kind_span: Span,
pub alias_span: Span,
/// Whole quoted header ID token, even if it is not a valid UUID. In that
/// case declaration.id is None. Missing/non-string ID values have no span.
pub id_literal_span: Option<Span>,
}
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
/// text remains available. Lexical errors and unmatched braces are facts only:
/// their absence does not establish syntactic or semantic validity.
///
/// A budget failure returns Err and no partial Outline. Include reachability,
/// project completeness and editor revision are intentionally not inferred here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outline<'a> {
pub source: &'a str,
pub tokens: Vec<TokenSpan>,
pub declarations: Vec<OutlineDeclaration>,
pub has_lexical_errors: bool,
pub has_unmatched_braces: bool,
}
/// Tolerant highlighting tokens cover the whole input, including incomplete
/// strings and unexpected characters. No model is created or modified.
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
}
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
if tokens.len() == max_tokens {
return Err(OutlineLimit {
resource: OutlineResource::Tokens,
limit: max_tokens,
observed_at_least: tokens.len().saturating_add(1),
offset: cursor,
});
}
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
@@ -224,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
span: Span { start, end: cursor },
});
}
tokens
Ok(tokens)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -1230,11 +1305,7 @@ impl Parser<'_> {
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
if !has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1962,8 +2033,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
/// spans remain available even when a later declaration contains errors.
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
/// the declaration parser rather than the UUID crate's broader accepted forms.
pub fn analyze(source: &str) -> Analysis {
let tokens = tokenize(source);
let (declarations, _) =
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
Analysis {
tokens,
diagnostics: lint(source),
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
}
}
/// Read exact current text without lint, parsing a model, stabilization or I/O.
/// Source bytes are admitted before token allocation; token/declaration limits
/// are checked before lexing the next token or materializing the next record.
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
if source.len() > limits.max_source_bytes {
return Err(OutlineLimit {
resource: OutlineResource::SourceBytes,
limit: limits.max_source_bytes,
observed_at_least: source.len(),
offset: 0,
});
}
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
let (declarations, has_unmatched_braces) =
declaration_outline(source, &tokens, limits.max_declarations)?;
Ok(Outline {
source,
tokens,
declarations,
has_lexical_errors,
has_unmatched_braces,
})
}
fn declaration_outline(
source: &str,
tokens: &[TokenSpan],
max_declarations: usize,
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
let significant = tokens
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
@@ -1972,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
let mut unmatched_close = false;
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
} else {
unmatched_close = true;
}
}
_ => {}
}
}
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -2007,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
}
j += 1;
}
if declarations.len() == max_declarations {
return Err(OutlineLimit {
resource: OutlineResource::Declarations,
limit: max_declarations,
observed_at_least: declarations.len().saturating_add(1),
offset: t.span.start,
});
}
let mut id = None;
let mut id_literal_span = None;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
@@ -2015,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
if let Some(value) = significant.get(j + 1)
&& value.class == TokenClass::String
{
id_literal_span = Some(value.span);
let text =
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
.unwrap();
id = Id::parse_str(&text).ok();
if has_uuid_header_shape(&text) {
id = Id::parse_str(&text).ok();
}
}
j += 2;
}
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
// A recognized header ID at EOF must remain inside the whole span.
// Keep the old next-token/body behavior; only the EOF fallback changes.
let header_end = significant
.get(j.saturating_sub(1))
.or_else(|| significant.last())
.map_or(alias_token.span.end, |token| token.span.end);
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
declarations.push(OutlineDeclaration {
declaration: DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
},
kind_span: t.span,
alias_span: alias_token.span,
id_literal_span,
});
}
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
Ok((declarations, has_unmatched_braces))
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2442,11 +2579,7 @@ pub(crate) mod legacy {
let value = self.quoted()?;
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
if !super::has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -0,0 +1,386 @@
//! Prepared outline tests. Explicit compatibility tests additionally exercise
//! the existing parsers/analyze; the outline entry itself never calls them.
use super::*;
fn limits() -> OutlineLimits {
OutlineLimits {
max_source_bytes: 1024 * 1024,
max_tokens: 100_000,
max_declarations: 1_000,
}
}
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
result
.declarations
.iter()
.find(|d| d.declaration.alias == alias)
.unwrap()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
let before = source.as_bytes().to_vec();
let result = outline(source, limits()).unwrap();
assert_eq!(result.source.as_bytes(), before);
assert_eq!(result.source.as_ptr(), source.as_ptr());
let d = declaration(&result, "responder");
assert_eq!(text(source, d.kind_span), "oa_actor");
assert_eq!(text(source, d.alias_span), "responder");
assert_eq!(
text(source, d.id_literal_span.unwrap()),
"\"\\u00300000000-0000-0000-0000-000000000001\""
);
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
let mut end = 0;
for token in &result.tokens {
assert_eq!(token.span.start, end);
assert!(token.span.start < token.span.end);
assert!(source.is_char_boundary(token.span.start));
assert!(source.is_char_boundary(token.span.end));
end = token.span.end;
}
assert_eq!(end, source.len());
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
}
#[test]
fn closed_id_at_eof_is_inside_the_declaration_extent() {
for source in [
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
"node placed id \"00000000-0000-0000-0000-000000000001\"",
] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
let literal = d.id_literal_span.unwrap();
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.end, source.len());
assert!(d.declaration.span.start <= literal.start);
assert!(literal.end <= d.declaration.span.end);
}
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
assert_eq!(d.declaration.span.end, source.len());
assert_eq!(d.declaration.id, None);
assert_eq!(d.id_literal_span, None);
}
}
#[test]
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
let first = outline(source, limits()).unwrap();
let second = outline(source, limits()).unwrap();
assert_eq!(first, second);
assert!(
first
.declarations
.iter()
.all(|d| d.declaration.id.is_none())
);
assert_eq!(declaration(&first, "missing").id_literal_span, None);
assert_eq!(declaration(&first, "historical").id_literal_span, None);
assert_eq!(
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
"\"not-a-uuid\""
);
// Balanced tokens are not a successful syntax/semantic validation claim.
assert!(!first.has_lexical_errors);
assert!(!first.has_unmatched_braces);
}
#[test]
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
let result = outline(source, limits()).unwrap();
assert_eq!(result.source, source);
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
assert!(result.has_lexical_errors);
assert!(result.has_unmatched_braces);
let ready = declaration(&result, "ready");
assert_eq!(
text(source, ready.declaration.span),
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
);
assert_eq!(
declaration(&result, "outer").declaration.span.end,
source.len()
);
assert_eq!(
declaration(&result, "pending").declaration.span.end,
source.len()
);
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
assert!(unmatched_close.has_unmatched_braces);
assert!(!unmatched_close.has_lexical_errors);
}
#[test]
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 1);
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
// There is deliberately no `valid` flag: the known-complete fact is scanning
// these bytes, not the missing body becoming legal or the draft being applied.
assert_eq!(result.source, source);
}
#[test]
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 5);
let duplicates: Vec<_> = result
.declarations
.iter()
.filter(|d| d.declaration.alias == "oa_activity")
.collect();
assert_eq!(duplicates.len(), 2);
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
assert!(
result
.tokens
.iter()
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
);
assert_eq!(
text(source, declaration(&result, "placement").kind_span),
"node"
);
assert_eq!(declaration(&result, "placement").declaration.id, None);
}
#[test]
fn bounds_refuse_before_returning_a_partial_outline() {
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
let admitted = outline(source, limits()).unwrap();
let exact = OutlineLimits {
max_source_bytes: source.len(),
max_tokens: admitted.tokens.len(),
max_declarations: 2,
};
assert_eq!(outline(source, exact).unwrap(), admitted);
for resource in [
OutlineResource::SourceBytes,
OutlineResource::Tokens,
OutlineResource::Declarations,
] {
let mut lower = exact;
match resource {
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
OutlineResource::Tokens => lower.max_tokens -= 1,
OutlineResource::Declarations => lower.max_declarations -= 1,
}
let error = outline(source, lower).unwrap_err();
assert_eq!(error.resource, resource);
assert!(error.observed_at_least > error.limit);
assert!(source.is_char_boundary(error.offset));
if resource == OutlineResource::Declarations {
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
}
}
let empty = outline(
"",
OutlineLimits {
max_source_bytes: 0,
max_tokens: 0,
max_declarations: 0,
},
)
.unwrap();
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
let error = outline(
unicode,
OutlineLimits {
max_source_bytes: 4,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::SourceBytes);
assert_eq!(error.offset, 0);
let error = outline(
unicode,
OutlineLimits {
max_tokens: 1,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Tokens);
assert_eq!(error.offset, unicode.find('\n').unwrap());
}
#[test]
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
let old = "oa_actor a \"A\" {}\nsource @a;";
let current = format!("// 航海 🚒\r\n{old}");
let old_outline = outline(old, limits()).unwrap();
let current_outline = outline(&current, limits()).unwrap();
let shift = current.len() - old.len();
let old_role = &old_outline.declarations[0];
let current_role = &current_outline.declarations[0];
assert_eq!(
current_role.alias_span.start,
old_role.alias_span.start + shift
);
assert_eq!(
current_role.kind_span.start,
old_role.kind_span.start + shift
);
assert_eq!(
current_role.declaration.span.end,
old_role.declaration.span.end + shift
);
assert_eq!(current_outline.source, current);
assert_eq!(old_outline.source, old);
assert_eq!(current_role.declaration.id, None);
assert!(!current_outline.source.contains(" id "));
// Workspace revision/stale-handle rejection belongs to the query/adapter,
// not a claim that an old owned span can be applied to any other string.
}
#[test]
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
let mut source = String::new();
for index in 0..600 {
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
}
source.push_str(&"}\n".repeat(600));
let result = outline(&source, limits()).unwrap();
assert_eq!(result.declarations.len(), 600);
assert!(!result.has_unmatched_braces);
assert_eq!(
result.declarations[0].declaration.span.end,
source.len() - 1
);
let error = outline(
&source,
OutlineLimits {
max_declarations: 599,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Declarations);
assert_eq!(error.observed_at_least, 600);
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
}
#[test]
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
for raw in [
"00000000000000000000000000000001",
"{00000000-0000-0000-0000-000000000001}",
"urn:uuid:00000000-0000-0000-0000-000000000001",
] {
assert!(
Id::parse_str(raw).is_ok(),
"The UUID crate alone is too permissive"
);
assert!(!has_uuid_header_shape(raw));
let literal = format!("\"{raw}\"");
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let before = modern.clone();
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(modern, before);
let errors = parse(&modern).unwrap_err();
assert!(
errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12")
&& error.span.start == modern.find(&literal).unwrap())
);
// Intentional compatibility correction: old analyze yielded Some(Id(1))
// here despite lint rejecting the header. It now shares outline's None.
let analysis = analyze(&modern);
assert_eq!(
analysis
.declarations
.iter()
.find(|d| d.alias == "model")
.unwrap()
.id,
None
);
assert!(
analysis
.diagnostics
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
assert!(
legacy_errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
}
}
#[test]
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
let expected = Id::from_u128(0xabcdef);
let canonical = expected.to_string();
let uppercase = canonical.to_uppercase();
assert_ne!(uppercase, canonical);
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
let literal = format!("\"{raw}\"");
let decoded: String = serde_json::from_str(&literal).unwrap();
assert!(has_uuid_header_shape(&decoded));
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, Some(expected));
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(result.source.as_bytes(), modern.as_bytes());
assert_eq!(parse(&modern).unwrap().id, expected);
let analysis = analyze(&modern);
assert_eq!(analysis.declarations[0].id, Some(expected));
assert!(
!analysis
.diagnostics
.iter()
.any(|error| error.severity == crate::validation::Severity::Error)
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
}
}
#[test]
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
let raw = "00000000-0000-0000-0000-00000000000Z";
assert!(has_uuid_header_shape(raw));
assert!(Id::parse_str(raw).is_err());
let literal = format!("\"{raw}\"");
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&source, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
assert!(
parse(&source)
.unwrap_err()
.iter()
.any(|error| error.message == "Invalid UUID identity.")
);
assert_eq!(analyze(&source).declarations[0].id, None);
}
+304 -29
View File
@@ -2,12 +2,16 @@ pub(crate) mod architecture_authoring;
#[cfg(test)]
mod architecture_authoring_tests;
mod checkpoints;
mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
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;
#[cfg(test)]
@@ -427,9 +431,20 @@ pub enum Message {
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
SourceViewport,
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
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),
SourceForSelection,
SourceNavigation(crate::source_navigation::Event),
SourceFindClose(crate::source_navigation::SymbolContext, Id),
Save(bool),
SavePath(Option<PathBuf>),
Saved(Result<Option<FileSnapshot>, SaveFailure>),
@@ -507,6 +522,18 @@ pub struct App {
pub source_jump: Option<text_editor::Cursor>,
source_select_line: bool,
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>,
diagram_lookup_in_flight: Option<Id>,
pending_diagram_reveal: Option<(
crate::diagram_usage::Context,
crate::diagram_usage::Usage,
u64,
)>,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_version: u64,
@@ -615,6 +642,14 @@ impl App {
source_jump: None,
source_select_line: false,
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,
diagram_lookup_in_flight: None,
pending_diagram_reveal: None,
model_issues,
migration: None,
source_version: 0,
@@ -661,18 +696,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(|| {
@@ -682,6 +736,9 @@ impl App {
.map(|b| b.cursor)
})
.flatten();
if let Some(cursor) = self.source_jump {
self.queue_source_reveal(cursor, false);
}
}
fn selected_architecture_placement(&self) -> Option<Id> {
self.active_diagram
@@ -714,6 +771,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();
@@ -743,7 +803,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) {
@@ -935,6 +995,9 @@ impl App {
let canvas_size = self.canvas_size;
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);
hosted.leave_document();
@@ -946,6 +1009,9 @@ impl App {
self.canvas_size = canvas_size;
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;
self.status = "Project ready".into();
@@ -1046,22 +1112,72 @@ impl App {
crate::model_menu::popup(self),
)
.into();
crate::behavior::scenario::focus::root(
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(self, self.reference_overlay(base)),
behavior::canvas_workspace::Scope::input_owner(self),
crate::source_navigation::interaction::observe(
crate::behavior::scenario::focus::root(
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(
self,
self.reference_overlay(crate::diagram_usage::view::overlay(self, base)),
),
behavior::canvas_workspace::Scope::input_owner(self),
),
self.scenario_focus_owner(),
),
self.scenario_focus_owner(),
self.source_symbols.raw_interaction.clone(),
)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
self.diagram_usages
.as_ref()
.map_or_else(Subscription::none, |panel| {
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
..
}) = event
else {
return None;
};
if !modifiers.is_empty() {
return None;
}
match key {
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8),
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8),
keyboard::Key::Named(keyboard::key::Named::Enter)
if status == iced::event::Status::Ignored =>
{
Some(0i8)
}
_ => None,
}
})
.with(panel.token)
.map(|(token, step)| {
Message::DiagramUsage(if step == 0 {
crate::diagram_usage::Event::Activate(token)
} else {
crate::diagram_usage::Event::Move(token, step)
})
})
}),
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
// Use one captured-Escape bridge: a focused Find input consumes
// Escape, while modal priority remains in the ordinary handler.
behavior::creation::subscription(
self.behavior.creation.has_pending() || self.behavior.source_active(),
self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (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, _| {
@@ -1108,6 +1224,24 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// 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
// dimensions or invalidate the exact pending destination epoch.
if self.pending_diagram_reveal.is_some()
&& Self::is_diagram_viewport(&message)
&& usage_viewport.is_none()
{
return Task::none();
}
self.reconcile_pending_diagram_reveal(&message);
if let Message::BehaviorControl(intent) = message {
return self.behavior_control(intent);
}
@@ -1118,6 +1252,19 @@ impl App {
);
let before = self.camera_boundary();
let task = self.update_inner(message);
if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal {
*epoch = self.canvas_epoch;
}
if let Some(surface) = usage_viewport {
self.finish_diagram_reveal(surface);
}
if self
.diagram_navigation_focus
.as_ref()
.is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none())
{
self.diagram_navigation_focus = None;
}
if !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
@@ -1207,6 +1354,78 @@ impl App {
)
}
fn update_inner(&mut self, message: Message) -> Task<Message> {
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();
}
// 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);
}
if !matches!(
&message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
) {
self.source_interaction();
}
if let Message::DiagramUsage(event) = message {
return self.diagram_usage_event(event);
}
if let Some(panel) = &self.diagram_usages {
if matches!(&message, Message::Request(Pending::Close(_))) {
self.diagram_usages = None;
return self.update(message);
}
if matches!(&message, Message::Escape) {
return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token));
}
if !matches!(
&message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
) {
return Task::none();
}
}
if let Some(task) = self.reference_message(&message) {
return task;
}
@@ -1274,7 +1493,7 @@ impl App {
| Message::CanvasResized(_)
| Message::Lint(_)
| Message::Linted(_, _)
| Message::SourceViewport
| Message::SourceViewport(_)
| Message::Request(Pending::Close(_))
)
{
@@ -1320,7 +1539,7 @@ impl App {
| Message::Exported(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::SourceViewport(_)
)
{
return Task::none();
@@ -1358,7 +1577,7 @@ impl App {
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::SourceViewport(_)
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
@@ -1388,6 +1607,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(_)
@@ -1432,6 +1663,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 {
@@ -1969,6 +2201,8 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.source_symbols.open);
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
@@ -1985,6 +2219,12 @@ impl App {
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.restore_source_editor_focus();
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
@@ -3023,6 +3263,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");
@@ -3036,6 +3277,9 @@ impl App {
if self.path.is_none()
|| self.path.as_ref() == Some(&receipt.target)
{
if self.path.as_ref() != Some(&receipt.target) {
self.source_workspace.reset_navigation_session();
}
self.path = Some(receipt.target);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.disk_project = Some(disk);
@@ -3104,13 +3348,30 @@ impl App {
}
}
Message::SourceRelated(location) => self.jump_to_source(location),
Message::SourceViewport => {
if let Some(cursor) = self.source_jump.take() {
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return iced::widget::operation::focus("source-editor");
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);
}
Message::DiagramUsage(event) => return self.diagram_usage_event(event),
Message::DiagramUsagesReady(completion) => {
return self.diagram_usages_ready(completion);
}
Message::DiagramRevealViewport(context, epoch, size) => {
if self
.pending_diagram_reveal
.as_ref()
.is_some_and(|(c, _, e)| *c == context && *e == epoch)
&& self
.diagram_reveal_viewport(&Message::DiagramRevealViewport(
context, epoch, size,
))
.is_some()
{
return self.handle(Message::CanvasResized(size));
}
}
Message::ApplySource => {
@@ -3157,6 +3418,7 @@ impl App {
return Task::none();
}
self.page = Page::Text;
self.source_find_instance = Id::new_v4();
self.source_navigation.open = true;
self.source_navigation.refresh(&self.source_editor.text());
return iced::widget::operation::focus(if go_to_line {
@@ -3165,13 +3427,22 @@ impl App {
"source-find"
});
}
Event::Close => self.source_navigation.open = false,
Event::Close => {
let was_open = self.source_navigation.open;
self.source_navigation.open = false;
if was_open {
self.restore_source_editor_focus();
}
}
Event::Query(query) => {
self.source_navigation.query = query;
self.source_navigation.refresh(&self.source_editor.text());
}
Event::Next(backwards) if self.page == Page::Text => {
self.source_jump = self.source_navigation.next(backwards);
if let Some(cursor) = self.source_jump {
self.queue_source_reveal(cursor, false);
}
if self.source_jump.is_none() {
self.status = "No matching source text.".into();
}
@@ -3182,8 +3453,7 @@ impl App {
.destination(self.source_editor.line_count())
{
Ok(cursor) => {
self.source_jump = Some(cursor);
self.source_select_line = true;
self.queue_source_reveal(cursor, true);
}
Err(error) => self.status = error,
},
@@ -3266,6 +3536,9 @@ impl App {
}
self.sync_source();
}
if self.path.as_ref() != Some(&receipt.path) {
self.source_workspace.reset_navigation_session();
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
self.hosted.session = None;
@@ -3866,7 +4139,7 @@ impl App {
self.path = Some(path);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.source_workspace = Workspace::new(&disk.bundle, false);
self.source_workspace.reachable = disk.reachable.clone();
self.source_workspace.set_reachable(disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = error.diagnostics().to_vec();
self.source_version += 1;
@@ -3918,11 +4191,13 @@ impl App {
let column = prefix
.rfind('\n')
.map_or(offset, |start| offset - start - 1);
self.source_jump = Some(text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
});
self.source_select_line = true;
self.queue_source_reveal(
text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
},
true,
);
}
fn create_source_file(&mut self) -> Result<(), String> {
@@ -70,6 +70,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -225,6 +225,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -299,6 +300,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
+146
View File
@@ -0,0 +1,146 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
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(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
}
}
@@ -0,0 +1,403 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}
+446
View File
@@ -0,0 +1,446 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
Message::DiagramUsage(Event::Open(Context {
document: self.canvas_document,
revision: self.document.revision(),
subject,
}))
}
fn usage_navigation_available(&self) -> bool {
!self.busy
&& !self.source_rename.open()
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.architecture.is_none()
&& !self.reference_input_active()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
}
/// Admit at most one expensive lookup, including one accepted Project clone.
/// A replacement panel waits for the previous worker's exact completion.
fn start_diagram_lookup(&mut self) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
return Task::none();
}
let (token, context) = (panel.token, panel.context);
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.usage_navigation_available()
{
let panel = self.diagram_usages.as_mut().unwrap();
panel.loading = false;
panel.notice = Some(
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
);
return Task::none();
}
let project = self.project().clone();
let presentation = self.behavior.topology.usage_snapshot();
self.diagram_lookup_in_flight = Some(token);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || {
let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario)
})
.await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
usage::Completion {
token,
context,
result,
}
},
Message::DiagramUsagesReady,
)
}
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_mut() else {
return Task::none();
};
panel.token = Id::new_v4();
panel.context.revision = self.document.revision();
panel.usages.clear();
panel.loading = true;
panel.page = 0;
panel.cursor = 0;
panel.destination = None;
panel.notice = Some(notice.into());
self.start_diagram_lookup()
}
/// Must run before modal/input dispatch. A closed or replaced panel still
/// owns its worker slot until this exact completion arrives.
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
if self.diagram_lookup_in_flight != Some(completion.token) {
return Task::none();
}
self.diagram_lookup_in_flight = None;
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.token != completion.token {
// A newer Open/refresh waited without cloning or spawning a second job.
return self.start_diagram_lookup();
}
let panel = self.diagram_usages.as_mut().unwrap();
if panel.context != completion.context || !panel.loading {
panel.loading = false;
panel.notice = Some(
"This lookup no longer matches the requested model. Close and try again.".into(),
);
return Task::none();
}
panel.loading = false;
match completion.result {
Ok(usages) => panel.usages = usages,
Err(error) => panel.notice = Some(error),
}
Task::none()
}
/// Selection validates one destination only; opening the chooser does the
/// whole-project scan on its worker, never on the GUI thread.
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
match surface {
Surface::Saved(diagram) => {
usage::saved_usages_for(self.project(), subject, Some(diagram))
}
Surface::Process(owner) => {
self.behavior
.topology
.diagram_usages(self.project(), owner, subject)
}
Surface::Scenario(owner) => {
self.behavior
.scenario
.diagram_usages(self.project(), owner, subject)
}
}
}
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();
}
if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none();
}
let mut panel = Panel::new(context, Vec::new());
panel.loading = true;
let filter_id = format!("diagram-usage-filter-{}", panel.token);
self.diagram_usages = Some(panel);
self.model_menu = None;
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
self.start_diagram_lookup(),
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();
}
if matches!(event, Event::Close(_)) {
self.diagram_usages = None;
if self.page == Page::Text {
self.restore_source_editor_focus();
}
return Task::none();
}
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.loading {
// Search and Close remain useful while loading; queued activation
// must not perform a synchronous fallback or choose an old row.
if let Event::Search(_, query) = event {
self.diagram_usages.as_mut().unwrap().search = query;
}
return Task::none();
}
match event {
Event::Move(_, direction) => {
let panel = self.diagram_usages.as_ref().unwrap();
let count = panel.choices(self.project()).len();
let panel = self.diagram_usages.as_mut().unwrap();
if count > 0 {
panel.cursor = if direction < 0 {
(panel.cursor + count - 1) % count
} else {
(panel.cursor + 1) % count
};
panel.page = panel.cursor / 6;
}
}
Event::Activate(_) => {
let panel = self.diagram_usages.as_ref().unwrap();
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
return self.diagram_usage_event(event);
}
}
Event::Search(_, query) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.search = query;
panel.page = 0;
panel.cursor = 0;
}
Event::Page(_, page) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.page = page;
panel.cursor = page.saturating_mul(6);
}
Event::Destination(_, surface) => {
let panel = self.diagram_usages.as_mut().unwrap();
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
panel.destination = surface;
panel.page = 0;
panel.cursor = 0;
}
}
Event::Choose(_, requested) => {
if !self.usage_navigation_available() {
return Task::none();
}
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
let fresh = self
.current_surface_usages(subject, requested.surface)
.into_iter()
.find(|u| u.subject == requested.subject && u.target == requested.target);
let Some(fresh) = fresh else {
return self.refresh_diagram_lookup(
"This representation changed. Review the refreshed destinations.",
);
};
if self.reveal_diagram_usage(&fresh) {
self.diagram_usages = None;
self.status = format!(
"{} · {} in {}",
self.project().named(subject),
fresh.role,
self.project().named(fresh.surface.id())
);
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|id| {
iced::widget::operation::snap_to(
id,
iced::widget::scrollable::RelativeOffset::START,
)
},
));
}
}
Event::Open(_) | Event::Close(_) => unreachable!(),
}
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
Target::Placement(id) => {
let Some(node) = p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
else {
return false;
};
(node.element, Some(*id))
}
Target::Port { port, placement } => (*port, Some(*placement)),
Target::StateRow {
placement,
relation,
} => (*relation, Some(*placement)),
Target::Edge(id)
| Target::ProcessLink(id)
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
let Some(diagram) = p.diagrams.get(&id) else {
return false;
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(id),
placement,
viewport: Some(viewport),
zoom,
pan,
fit: false,
..Default::default()
};
self.behavior.active_owner = Some(machine);
self.page = Page::Behavior;
} else {
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
(self.window_size.width - 550.).max(280.),
(self.window_size.height - 284.).max(240.),
));
(self.zoom, self.pan) =
usage::camera_for(&usage.target, usage.bounds, viewport);
self.fit_to_view = false;
self.page = Page::Diagram;
}
}
Surface::Process(owner) => {
if !self.behavior.topology.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
self.connection = None;
self.last_workspace_page = self.page;
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
self.explorer_page = 0;
}
self.normalize_creation();
self.reveal_explorer_selection();
self.refresh_draft();
self.diagram_navigation_focus = Some(usage::Focus {
context: Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage: usage.clone(),
selected,
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage.clone(),
self.canvas_epoch,
));
true
}
/// Validated presentation identity. Edit selection and Delete capture remain
/// semantic; a port owner or allocation work box is only a visual marker.
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
let focus = self.diagram_navigation_focus.as_ref()?;
let current_surface = match surface {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
}
Surface::Process(owner) | Surface::Scenario(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
};
(current_surface
&& focus.usage.surface == surface
&& focus.context.document == self.canvas_document
&& focus.context.revision == self.document.revision()
&& focus.selected == self.selected)
.then_some(&focus.usage)
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -213,6 +213,14 @@ impl App {
self.pending = None;
return Task::none();
}
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
@@ -345,6 +353,14 @@ impl App {
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
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,448 @@
//! 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};
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,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();
}
+508
View File
@@ -0,0 +1,508 @@
//! Current draft navigation, isolated from model application and disk discovery.
use super::*;
use crate::source_navigation::{self as nav, query};
use std::sync::atomic::Ordering;
fn admission_notice(error: query::AdmissionError) -> String {
use nav::symbols::{BuildError, InputProblem, Resource};
let (limit, resource) = match error {
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
language::OutlineResource::SourceBytes => Resource::SourceBytes,
language::OutlineResource::Tokens => Resource::Tokens,
language::OutlineResource::Declarations => Resource::Records,
}),
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
},
};
let unit = match resource {
Resource::Files => "loaded files",
Resource::SourceBytes => "source bytes",
Resource::PathBytes => "bytes in file paths",
Resource::Tokens => "source tokens",
Resource::Records => "declarations and references",
};
format!(
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
)
}
impl App {
pub fn source_symbol_context(&self) -> nav::SymbolContext {
nav::SymbolContext {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
}
}
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
Message::SourceSymbols(self.source_symbol_context(), event)
}
pub fn source_find_close_message(&self) -> Message {
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
}
pub fn source_key(&self) -> query::SnapshotKey {
query::SnapshotKey {
workspace: self.source_workspace.session(),
revision: self.source_workspace.revision(),
}
}
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
let files: Vec<_> = self
.source_workspace
.files
.iter()
.map(|(path, buffer)| query::DraftFile {
path,
text: &buffer.text,
})
.collect();
let accepted = self.document.bundle();
let validated: Vec<_> = accepted
.files
.iter()
.map(|(path, text)| query::DraftFile { path, text })
.collect();
use_input(query::Input {
key: self.source_key(),
entry: &self.source_workspace.entry,
files: &files,
applied: self.source_workspace.applied,
validation: self
.source_workspace
.applied
.then_some(query::ValidatedSources {
entry: &accepted.entry,
files: &validated,
}),
})
}
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()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
/// Mounted widget IDs change when another interaction supersedes a reveal.
/// A queued focus operation therefore cannot find a subsequently reused editor.
pub fn source_editor_id(&self) -> iced::widget::Id {
iced::widget::Id::from(format!(
"source-editor-{}-{}-{}",
self.source_workspace.session(),
self.source_workspace.active,
self.source_symbols.interaction
))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
.as_ref()
.map(|reveal| reveal.ticket)
}
pub(super) fn source_interaction(&mut self) {
self.source_symbols.interaction = Id::new_v4();
self.source_symbols.request = None;
self.source_symbols.reveal = None;
self.source_jump = None;
}
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
self.source_jump = Some(cursor);
self.source_select_line = select_line;
self.source_symbols.reveal = Some(nav::Reveal {
ticket: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
snapshot: self.source_key(),
path: self.source_workspace.active.clone(),
});
}
pub(super) fn restore_source_editor_focus(&mut self) {
let cursor = self.source_editor.cursor();
if self.source_navigation_available()
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
}
}
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
let Some(reveal) = &self.source_symbols.reveal else {
return Task::none();
};
if Some(reveal.ticket) != ticket
|| reveal.interaction != self.source_symbols.interaction
|| !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
reveal.raw_interaction,
)
|| reveal.snapshot != self.source_key()
|| reveal.path != self.source_workspace.active
|| !self.source_navigation_available()
{
return Task::none();
}
let raw_interaction = reveal.raw_interaction;
self.source_symbols.reveal = None;
if let Some(cursor) = self.source_jump.take() {
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
return Task::none();
}
self.source_editor.move_to(cursor);
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()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
if !self.source_navigation_available() {
return Task::none();
}
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
self.source_symbols.open = true;
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
return Task::none();
}
let task = self.start_source_query(query::Action::Definition);
self.source_diagram_job = self
.source_symbols
.request
.as_ref()
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
};
if results.stamp != stamp
|| stamp.snapshot != self.source_key()
|| self
.source_symbols
.cache
.as_ref()
.is_none_or(|cache| !self.source_snapshot_matches(cache))
{
self.source_symbols.notice =
Some("Source changed. Run navigation again for current locations.".into());
return Task::none();
}
let destination = if reference {
results
.references
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
} else {
results
.definitions
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
};
if let Some((path, start, end)) = destination {
self.source_symbol_jump(path, start, end);
}
}
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
}
Task::none()
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
) {
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
return Task::none();
}
if self.source_symbols.in_flight.is_some() {
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
return Task::none();
}
let cursor = self.source_editor.cursor();
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
self.source_symbols.notice =
Some("Place the cursor on an alias or its @ reference.".into());
return Task::none();
};
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
}
};
let request = nav::Request {
job: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
stamp: query::QueryStamp {
snapshot: self.source_key(),
serial: self.source_symbols.serial,
},
path: self.source_workspace.active.clone(),
offset,
cursor,
action,
};
let cache = self
.source_symbols
.cache
.as_ref()
.filter(|cache| self.source_snapshot_matches(cache))
.cloned();
let input = if cache.is_none() {
match self
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
{
Ok(input) => Some(input),
Err(error) => {
self.source_symbols.notice = Some(admission_notice(error));
return Task::none();
}
}
} else {
None
};
self.source_symbols.in_flight = Some(request.job);
self.source_symbols.request = Some(request.clone());
self.source_symbols.results = None;
self.source_symbols.notice = None;
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
let snapshot = match cache {
Some(cache) => cache,
None => Arc::new(
input
.expect("admitted input for uncached request")
.capture()
.map_err(admission_notice)?,
),
};
let results = snapshot
.query(query::Query {
stamp: worker_request.stamp,
path: &worker_request.path,
byte_offset: worker_request.offset,
action: worker_request.action,
})
.map_err(|error| match error {
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
})?;
Ok((snapshot, Arc::new(results)))
})
.await
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
nav::Completion { request, result }
},
Message::SourceSymbolsReady,
)
}
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
let request = completion.request;
if self.source_symbols.in_flight != Some(request.job) {
return Task::none();
}
self.source_symbols.in_flight = None;
let diagram_query = self.source_diagram_job == Some(request.job);
if diagram_query {
self.source_diagram_job = None;
}
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
&self.source_symbols.raw_interaction,
request.raw_interaction,
)
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
if diagram_query {
if results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
&& let query::Identity::Unique(identity) = row.declaration.identity
&& row.membership == query::Membership::Included
&& row.evidence == query::Evidence::Validated
&& self.project().contains(identity.id())
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.source_symbols.open = false;
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: identity.id(),
},
));
}
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
}
}
Ok(_) => {
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
}
Err(error) => self.source_symbols.notice = Some(error),
}
Task::none()
}
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
let cursor = text_editor::Cursor {
position: text_editor::Position {
line: start.line,
column: start.byte_column,
},
selection: Some(text_editor::Position {
line: end.line,
column: end.byte_column,
}),
};
if self
.source_workspace
.files
.get(path)
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
{
self.source_symbols.notice =
Some("That source location is no longer available. Run navigation again.".into());
return;
}
if let Some(buffer) = self
.source_workspace
.files
.get(&self.source_workspace.active)
{
let origin = nav::HistoryEntry {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
.retain(|entry| entry.session == origin.session);
if self.source_symbols.back.len() == 64 {
self.source_symbols.back.remove(0);
}
self.source_symbols.back.push(origin);
}
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
fn source_symbol_back(&mut self) {
while let Some(origin) = self.source_symbols.back.pop() {
if origin.session != self.source_workspace.session() {
continue;
}
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
return;
};
let unchanged = origin.generation == buffer.generation();
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
} else {
buffer.cursor
};
if !nav::exact_cursor(&buffer.text, cursor) {
self.source_symbols.notice = Some(
"The previous location changed. Open the file to choose a current position."
.into(),
);
return;
}
self.source_workspace
.switch(&origin.path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
self.source_symbols.notice =
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
return;
}
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -1156,6 +1156,17 @@ struct Line {
label_at: Point,
dashed: bool,
}
impl Line {
fn label_bounds(&self) -> Rectangle {
Rectangle::new(
self.label_at,
Size::new(
(self.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
}
}
struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
@@ -1831,17 +1842,7 @@ impl Scene {
.or_else(|| {
self.lines
.iter()
.find(|line| {
!line.label.is_empty()
&& Rectangle::new(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
.contains(point)
})
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.map(|line| line.id)
})
.or_else(|| self.near_sequence_item(point))
@@ -17,6 +17,7 @@ pub fn keyboard_enabled(app: &crate::app::App) -> bool {
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& app.diagram_usages.is_none()
&& !app.reference_input_active()
}
@@ -5,6 +5,7 @@ use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
pub(crate) mod focus;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
@@ -0,0 +1,207 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
/// actual character of this field, rather than an ellipsis at its start.
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
let start = normalized_prefix.len()
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
let painted = crate::views::short(full, 32);
let retained = normalized
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
start < retained
}
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
// they must not inflate its target count.
if !result
.iter()
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
});
}
};
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(element) = p.elements.get(&item.id) else {
continue;
};
if subject == item.id {
// A fragment's selectable header is useful even when its frame is
// much larger than the viewport.
let mut bounds = item.rect;
if item.marker == Some(Kind::Occurrence) {
bounds.height = bounds.height.min(38.);
}
add(item.id, bounds, "scenario object");
}
let represented = match element.shared() {
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
OccurrenceBody::SequenceReference { sequence } => {
sequence.map(|id| (id, "process reference"))
}
OccurrenceBody::ScenarioUse { scenario, .. } => {
scenario.map(|id| (id, "scenario reference"))
}
// An incomplete message fallback box only paints the occurrence
// name and a repair hint, not its exchange or operation.
_ => None,
},
_ => None,
};
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
let _ = id;
add(item.id, item.rect, role);
}
}
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
let Some(element) = p.elements.get(&line.id) else {
continue;
};
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = element.shared()
else {
continue;
};
let bounds = line.label_bounds();
if subject == line.id || *exchange == Some(subject) {
add(line.id, bounds, "message occurrence");
}
// Replies derive their operation from the exact call, matching the
// operation label formatter rather than scanning raw reference fields.
if let Ok(operations::OperationResolution::Selected(operation)) =
operations::resolve(p, line.id)
&& let Some(label) = crate::data_values::operation_label(p, element)
&& let Some(prefix) = line.label.strip_suffix(&label)
{
let allocation = &p.relations[&operation.allocation];
let operation_prefix = if matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
) {
"Reply operation from Call: "
} else {
"Operation: "
};
let fields = [
(allocation.source(), format!("{prefix}{operation_prefix}")),
(
allocation.target(),
format!(
"{prefix}{operation_prefix}{} / ",
p.named(allocation.source())
),
),
(
allocation.id,
format!(
"{prefix}{operation_prefix}{} / {} · ",
p.named(allocation.source()),
p.named(allocation.target())
),
),
];
for (id, prefix) in fields {
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
add(line.id, bounds, "operation label");
}
}
}
}
if subject == owner {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
},
);
}
result
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
}
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return false;
}
let id = match target {
Target::Whole => owner,
Target::ScenarioObject(id) => *id,
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.into_iter()
.find(|u| &u.target == target)
else {
return false;
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan;
true
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,276 @@
use super::*;
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let layer = p.elements[&owner].layer();
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
&& !steps.contains(&id)
{
steps.push(id);
}
id
}
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
state
.diagram_usages(p, owner, subject)
.into_iter()
.filter_map(|u| match u.target {
Target::ScenarioObject(id) => Some(id),
_ => None,
})
.collect()
}
#[test]
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
for (layer, work_kind, participant_kind) in [
(Layer::Oa, Kind::Activity, Kind::Entity),
(Layer::Sa, Kind::Function, Kind::SystemActor),
] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, layer, "Caller", None)
.unwrap();
let other = p
.add_element(Kind::Scenario, layer, "Definition", None)
.unwrap();
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
let participant = p
.add_element(participant_kind, layer, "Operator", None)
.unwrap();
let lanes = ["First role", "Second role"].map(|name| {
let id = p
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(participant),
};
id
});
let first = add_occurrence(
&mut p,
owner,
"First action",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[0]),
},
);
let fragment = add_occurrence(
&mut p,
owner,
"Decision",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branch = add_occurrence(
&mut p,
fragment,
"When ready",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let nested = add_occurrence(
&mut p,
branch,
"Again",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[1]),
},
);
let inside = add_occurrence(
&mut p,
other,
"Hidden inside definition",
OccurrenceBody::Action {
action: Some(unused),
lifeline: None,
},
);
let use_a = add_occurrence(
&mut p,
owner,
"Use A",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let use_b = add_occurrence(
&mut p,
owner,
"Use B",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let before = p.clone();
let state = State::default();
assert_eq!(
targets(&state, &p, owner, work),
BTreeSet::from([first, nested])
);
assert_eq!(
targets(&state, &p, owner, participant),
BTreeSet::from(lanes)
);
assert_eq!(
targets(&state, &p, owner, other),
BTreeSet::from([use_a, use_b])
);
assert!(targets(&state, &p, owner, unused).is_empty());
assert!(targets(&state, &p, owner, inside).is_empty());
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
assert_eq!(
state.diagram_usages(&p, owner, owner)[0].target,
Target::Whole
);
assert_eq!(p, before);
}
}
#[test]
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let mut state = State::default();
assert_eq!(
targets(&state, &p, f.owner, f.exchange),
BTreeSet::from([f.call, f.reply])
);
let before = p.clone();
let target = Target::ScenarioObject(f.reply);
state.viewport(Size::new(900., 500.));
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
let usage = state
.diagram_usages(&p, f.owner, f.reply)
.into_iter()
.find(|u| u.target == target)
.unwrap();
let zoom = state
.presentation
.scale(Scene::sequence(&p, f.owner, f.reply).size);
let centered = Point::new(
usage.bounds.center_x() * zoom,
usage.bounds.center_y() * zoom,
) + state.presentation.pan;
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
assert!(zoom >= 0.5);
assert_eq!(p, before);
// Keeping an orphan semantic occurrence does not keep its painted target.
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
let camera = state.camera_boundary();
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
assert!(camera == state.camera_boundary());
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
// A message whose endpoints are incomplete paints only its repair box.
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { receiver, .. },
..
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
{
*receiver = None;
}
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
assert_eq!(
targets(&state, &p, f.owner, f.call),
BTreeSet::from([f.call])
);
}
#[test]
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
assert!(label_field_visible(
"1. call · X · Operation: I / O",
"1. call · X · Operation: ",
"I"
));
assert!(!label_field_visible(
"1234567890123456789012345678901Ixyz",
"1234567890123456789012345678901",
"Ixyz"
));
assert!(!label_field_visible(
"A very long occurrence label · Operation: I",
"A very long occurrence label · Operation: ",
"I"
));
assert!(label_field_visible(
"call\n\t· Operation: 界",
"call\n\t· Operation: ",
"界"
));
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
}
#[test]
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
let (interface, item) = (
p.relations[&allocation].source(),
p.relations[&allocation].target(),
);
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
p.elements.get_mut(&id).unwrap().name = name.into();
}
p.relations.get_mut(&exchange).unwrap().name = "x".into();
p.relations.get_mut(&allocation).unwrap().name = "A".into();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
*operation = Some(OperationUse {
allocation: Some(allocation),
});
}
assert!(matches!(
operations::resolve(&p, call),
Ok(operations::OperationResolution::Selected(_))
));
let state = State::default();
let before = p.clone();
assert_eq!(
targets(&state, &p, owner, interface),
BTreeSet::from([call])
);
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
assert!(targets(&state, &p, owner, allocation).is_empty());
assert_eq!(p, before);
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
assert!(targets(&state, &p, owner, interface).is_empty());
assert!(targets(&state, &p, owner, item).is_empty());
}
@@ -27,6 +27,8 @@ pub struct StateCanvas<'a> {
pub diagram: Id,
pub context: Context,
pub selected: Id,
/// Presentation-only navigation marker; never an authoring placement.
pub navigation_focus: Option<Rectangle>,
pub tool: Tool,
pub zoom: f32,
pub pan: Vector,
@@ -415,6 +417,171 @@ fn state_action_label(project: &Project, relation: &Relation) -> String {
format!("{phase} / {}", project.named(target))
}
/// Match a typed label field by its structural offset, never by a display-name
/// substring (names may duplicate or also occur in free-form guards).
fn retained_label_field(full: &str, painted: &str, start: usize) -> bool {
start
< full
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>()
}
fn transition_label_role(
project: &Project,
relation: &Relation,
subject: Id,
width: f32,
) -> Option<&'static str> {
let full = routing::state_transition_summary(project, relation);
transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.))
}
fn transition_label_role_in_text(
project: &Project,
relation: &Relation,
subject: Id,
painted: &str,
) -> Option<&'static str> {
let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
effect,
..
} = &relation.body
else {
return None;
};
if project.named(subject).trim().is_empty() {
return None;
}
let full = routing::state_transition_summary(project, relation);
let mut offset = format!("{transition_kind:?} · ").len();
for id in triggers {
if *id == subject && retained_label_field(&full, painted, offset) {
return Some("transition trigger");
}
offset += project.named(*id).len() + " OR ".len();
}
if !effects.is_empty() {
// The canonical routing summary appends typed effects before optional
// authored effect prose. Derive its offset from that exact suffix.
let mut suffix = format!(
" / {}",
effects
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(", ")
);
if !effect.is_empty() {
suffix.push_str(&format!(" / {effect}"));
}
if let Some(prefix) = full.strip_suffix(&suffix) {
offset = prefix.len() + " / ".len();
for id in effects {
if *id == subject && retained_label_field(&full, painted, offset) {
return Some("transition effect");
}
offset += project.named(*id).len() + ", ".len();
}
}
}
None
}
/// Actual state compartments and painted transition-reference labels. Generic
/// projected node/edge identities are handled by the shared saved-view query.
pub(crate) fn diagram_usages(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
subject: Id,
) -> Vec<crate::diagram_usage::Usage> {
use crate::diagram_usage::{Surface, Target, Usage};
if diagram.projection.state_machine().is_none() {
return vec![];
}
let mut result = Vec::new();
for node in &view.nodes {
for (relation, bounds) in state_action_rows(project, node) {
let label = state_action_label(project, relation);
let painted = short(&label, bounds.width - 12., 11.);
let role = if relation.id == subject {
Some("state action")
} else if relation.target() == subject
&& retained_label_field(
&label,
&painted,
label.len() - project.named(subject).len(),
)
{
Some("state action work")
} else {
None
};
if let Some(role) = role {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::StateRow {
placement: node.id,
relation: relation.id,
},
role: role.into(),
bounds,
});
}
}
for (relation, bounds) in internal_rows(project, node) {
let role = if relation.id == subject {
Some("internal transition")
} else {
transition_label_role(project, relation, subject, bounds.width - 12.)
};
if let Some(role) = role {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::StateRow {
placement: node.id,
relation: relation.id,
},
role: role.into(),
bounds,
});
}
}
}
for edge in routing::route_diagram(project, diagram, view)
.edges
.values()
{
let Some(relation) = project.relations.get(&edge.id) else {
continue;
};
if edge.id == subject || edge.label.text.is_empty() {
continue;
}
// Routing already shortens external summaries before the canvas applies
// its width limit. Compare against precisely that doubly shortened text.
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::Edge(edge.id),
role: role.into(),
bounds: crate::diagram_usage::edge_focus_bounds(edge),
});
}
}
result
}
impl StateCanvas<'_> {
fn scale(&self) -> f32 {
if self.zoom.is_finite() {
@@ -1148,6 +1315,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
overlay.with_clip(content, |frame| {
frame.translate(self.offset());
frame.scale(self.scale());
if let Some(bounds) = self.navigation_focus {
frame.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2.5 / self.scale()),
);
}
if let Some(at) = world
&& self.editable
{
@@ -1575,6 +1750,189 @@ mod tests {
}
}
#[test]
fn diagram_usage_state_rows_and_label_references_follow_actual_projection() {
use crate::diagram_usage::Target;
let mut f = fixture();
let work = f
.project
.add_element(Kind::Function, Layer::Sa, "Act", None)
.unwrap();
let event = f
.project
.add_element(Kind::Event, Layer::Sa, "E", None)
.unwrap();
let unused = f
.project
.add_element(Kind::Event, Layer::Sa, "E", None)
.unwrap();
let action = f
.project
.relate(RelationKind::StateAction, "Entry", f.a, work);
let internal = f
.project
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
let external = f
.project
.relate(RelationKind::StateTransition, "External", f.a, f.b);
for (id, kind) in [
(internal, TransitionKind::Internal),
(external, TransitionKind::External),
] {
if let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
*triggers = vec![event];
*effects = vec![work];
}
}
let before = f.project.clone();
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id;
let action_target = Target::StateRow {
placement,
relation: action,
};
let internal_target = Target::StateRow {
placement,
relation: internal,
};
assert_eq!(
diagram_usages(&f.project, diagram, &view, action)[0].target,
action_target
);
assert_eq!(
diagram_usages(&f.project, diagram, &view, internal)[0].target,
internal_target
);
let events = diagram_usages(&f.project, diagram, &view, event);
assert_eq!(events.len(), 2);
assert!(events.iter().any(|u| u.target == internal_target));
assert!(events.iter().any(|u| u.target == Target::Edge(external)));
assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty());
// External edge identity belongs to the shared saved-view adapter.
assert!(diagram_usages(&f.project, diagram, &view, external).is_empty());
let work_usages = diagram_usages(&f.project, diagram, &view, work);
assert!(work_usages.iter().any(|u| u.target == action_target));
assert!(work_usages.iter().any(|u| u.target == internal_target));
let row = work_usages
.iter()
.find(|u| u.target == action_target)
.unwrap();
let canvas = canvas(&f, Tool::Select, 1.);
let scene = canvas.scene(&State::default()).unwrap();
assert_eq!(
canvas.hit(&scene, row.bounds.center()),
Some(Hit::StateAction(action))
);
assert_eq!(f.project, before);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.collapsed = true;
let diagram = &f.project.diagrams[&f.diagram];
let folded = projection::project_diagram(&f.project, diagram);
assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty());
assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty());
}
#[test]
fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() {
let mut f = fixture();
let early = f
.project
.add_element(Kind::Event, Layer::Sa, "A", None)
.unwrap();
let late = f
.project
.add_element(Kind::Event, Layer::Sa, "B", None)
.unwrap();
let relation = f
.project
.relate(RelationKind::StateTransition, "Transition", f.a, f.b);
if let RelationBody::StateTransition {
triggers, guard, ..
} = &mut f.project.relations.get_mut(&relation).unwrap().body
{
*triggers = vec![early, late];
*guard = "B".into();
}
let relation = &f.project.relations[&relation];
assert_eq!(
transition_label_role(&f.project, relation, early, 84.),
Some("transition trigger")
);
assert_eq!(transition_label_role(&f.project, relation, late, 84.), None);
assert_eq!(
transition_label_role(&f.project, relation, late, 800.),
Some("transition trigger")
);
assert!(!retained_label_field("1234A678", "1234…", 4));
}
#[test]
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
use crate::diagram_usage::Target;
let mut f = fixture();
let first = f
.project
.add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None)
.unwrap();
let second = f
.project
.add_element(Kind::Event, Layer::Sa, "Second", None)
.unwrap();
let external = f
.project
.relate(RelationKind::StateTransition, "External", f.a, f.b);
let internal = f
.project
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
for (id, kind) in [
(external, TransitionKind::External),
(internal, TransitionKind::Internal),
] {
if let RelationBody::StateTransition {
transition_kind,
triggers,
..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
*triggers = vec![first, second];
}
}
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let routed = routing::route_diagram(&f.project, diagram, &view);
let edge = &routed.edges[&external];
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
// Independent painted-text oracle: 11 prefix characters + 35 As, then
// only the space before OR survives the routing limit.
assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35)));
assert!(!painted.contains("Second"));
let usages = diagram_usages(&f.project, diagram, &view, second);
assert!(!usages.iter().any(|u| u.target == Target::Edge(external)));
assert!(usages.iter().any(
|u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal)
));
assert!(
diagram_usages(&f.project, diagram, &view, first)
.iter()
.any(|u| u.target == Target::Edge(external))
);
}
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
StateCanvas {
project: &f.project,
@@ -1586,6 +1944,7 @@ mod tests {
epoch: 3,
},
selected: f.machine,
navigation_focus: None,
tool,
zoom,
pan: Vector::new(20., 30.),
@@ -165,6 +165,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
diagram: id,
context,
selected: app.selected,
navigation_focus: app
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
.map(|usage| usage.bounds),
tool: state.tool,
zoom: state.zoom,
pan: state.pan,
+130 -45
View File
@@ -5,6 +5,7 @@ use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
mod usages;
mod view;
use direct::Input;
pub(crate) use direct::{
@@ -910,6 +911,13 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreviewLink {
relation: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
}
struct GraphNode {
id: Id,
rect: Rectangle,
@@ -918,26 +926,98 @@ struct GraphNode {
control: Option<ControlKind>,
source: Option<Id>,
preview: Vec<(OccurrenceEndpoint, String)>,
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
preview_links: Vec<PreviewLink>,
preview_total: usize,
}
impl GraphNode {
fn preview_link_points(&self, link: &PreviewLink) -> Option<[Point; 2]> {
if ![&link.source, &link.target]
.into_iter()
.all(|endpoint| self.preview.iter().any(|(shown, _)| shown == endpoint))
{
return None;
}
let a = self.endpoint_rect(&link.source);
let b = self.endpoint_rect(&link.target);
Some([
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
])
}
fn preview_link_visible_segment(&self, link: &PreviewLink) -> Option<[Point; 2]> {
visible_preview_segment(
self.preview_link_points(link)?,
(0..self.preview.len()).map(|index| self.preview_rect(index)),
)
}
fn preview_rect(&self, index: usize) -> Rectangle {
Rectangle::new(
Point::new(
self.rect.x + 12. + (index % 2) as f32 * 125.,
self.rect.y + 118. + (index / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
}
fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle {
self.preview
.iter()
.position(|(candidate, _)| candidate == endpoint)
.map(|i| {
Rectangle::new(
Point::new(
self.rect.x + 12. + (i % 2) as f32 * 125.,
self.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
})
.map(|index| self.preview_rect(index))
.unwrap_or(self.rect)
}
}
/// Preview tiles are painted after their arrows. Focus only a remaining line
/// segment, excluding opaque tile interiors and their one-pixel borders.
fn visible_preview_segment(
[a, b]: [Point; 2],
tiles: impl Iterator<Item = Rectangle>,
) -> Option<[Point; 2]> {
let delta = b - a;
let length = (delta.x * delta.x + delta.y * delta.y).sqrt();
if !length.is_finite() || length <= 0.5 {
return None;
}
let mut hidden = Vec::new();
for tile in tiles {
let mut low = 0_f32;
let mut high = 1_f32;
for (origin, step, min, max) in [
(a.x, delta.x, tile.x - 1., tile.x + tile.width + 1.),
(a.y, delta.y, tile.y - 1., tile.y + tile.height + 1.),
] {
if step.abs() <= f32::EPSILON {
if origin < min || origin > max {
high = -1.;
break;
}
} else {
let first = (min - origin) / step;
let last = (max - origin) / step;
low = low.max(first.min(last));
high = high.min(first.max(last));
}
}
if high > low {
hidden.push((low, high));
}
}
hidden.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut cursor = 0_f32;
let mut best = (0_f32, 0_f32);
for (low, high) in hidden.into_iter().chain([(1., 1.)]) {
if low - cursor > best.1 - best.0 {
best = (cursor, low);
}
cursor = cursor.max(high);
}
((best.1 - best.0) * length > 0.5).then(|| [a + delta * best.0, a + delta * best.1])
}
struct GraphEdge {
id: Id,
source: Id,
@@ -986,6 +1066,8 @@ struct Graph<'a> {
ordered: bool,
size: Size,
selected: Id,
selected_preview: Option<OccurrenceEndpoint>,
selected_preview_link: Option<PreviewLink>,
scale: f32,
}
impl<'a> Graph<'a> {
@@ -1134,17 +1216,16 @@ impl<'a> Graph<'a> {
occurrence: r.target(),
})
})
.map(|r| {
(
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
},
)
.map(|r| PreviewLink {
relation: r.id,
source: OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
target: OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
},
})
.collect();
}
@@ -1319,6 +1400,8 @@ impl<'a> Graph<'a> {
ordered: !order.is_empty(),
size,
selected,
selected_preview: None,
selected_preview_link: None,
scale: 1.,
}
}
@@ -1548,39 +1631,41 @@ impl canvas::Program<Message> for Graph<'_> {
let mini: BTreeMap<_, _> = n
.preview
.iter()
.enumerate()
.map(|(i, (id, _))| {
(
id.clone(),
Rectangle::new(
Point::new(
n.rect.x + 12. + (i % 2) as f32 * 125.,
n.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
),
)
})
.map(|(id, _)| (id.clone(), n.endpoint_rect(id)))
.collect();
for (a, b) in &n.preview_links {
let a = mini[a];
let b = mini[b];
for link in &n.preview_links {
let Some(points) = n.preview_link_points(link) else {
continue;
};
let selected = self.selected_preview_link.as_ref() == Some(link);
draw_arrow(
&mut f,
&[
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
style::MUTED,
1.,
&points,
if selected { style::TEAL } else { style::MUTED },
if selected { 3. } else { 1. },
);
}
for (id, name) in &n.preview {
let r = mini[id];
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
let selected = self.selected_preview.as_ref() == Some(id);
f.fill_rectangle(
r.position(),
r.size(),
if selected {
Color::from_rgb8(224, 247, 242)
} else {
Color::WHITE
},
);
f.stroke(
&Path::rectangle(r.position(), r.size()),
Stroke::default().with_color(style::LINE),
Stroke::default()
.with_color(if selected {
style::TEAL
} else {
style::LINE
})
.with_width(if selected { 2.5 } else { 1. }),
);
draw_text(
&mut f,
@@ -0,0 +1,274 @@
//! Read-only usages of the actual process graph, including bounded reuse tiles.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The only presentation inputs needed by a background usage query. Drafts,
/// focus, camera, history and widget state never cross the worker boundary.
#[derive(Clone)]
pub(crate) struct UsageSnapshot {
identity: Option<(Id, Id)>,
expanded: BTreeSet<Id>,
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
}
impl UsageSnapshot {
pub(crate) fn into_query_state(self) -> State {
State {
identity: self.identity,
expanded: self.expanded,
contextual_expanded: self.contextual_expanded,
..State::default()
}
}
}
impl State {
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
identity: self.identity,
expanded: self.expanded.clone(),
contextual_expanded: self.contextual_expanded.clone(),
}
}
}
fn owner_exists(p: &Project, owner: Id) -> bool {
p.elements
.get(&owner)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
}
fn occurrence_role(p: &Project, occurrence: Id, subject: Id) -> Option<&'static str> {
let e = p.elements.get(&occurrence)?;
if occurrence == subject {
return Some("contribution");
}
match e.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(id), ..
},
..
} if *id == subject => Some("action occurrence"),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(id), ..
},
..
} if *id == subject => Some("exchange occurrence"),
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: Some(id) },
..
} if *id == subject => Some("reused process / chain"),
_ => None,
}
}
impl Graph<'_> {
fn usage_bounds(&self, target: &Target) -> Option<Rectangle> {
match target {
Target::Whole => Some(Rectangle::new(Point::ORIGIN, self.size)),
Target::ProcessOccurrence(endpoint) => {
let root = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
let node = self.nodes.iter().find(|node| node.id == root)?;
if endpoint.references.is_empty() {
Some(node.rect)
} else {
// endpoint_rect intentionally has a proxy fallback for other callers;
// a usage promises an actual tile and must never take that fallback.
node.preview
.iter()
.any(|(shown, _)| shown == endpoint)
.then(|| node.endpoint_rect(endpoint))
}
}
Target::ProcessLink(id) => {
let edge = self.edges.iter().find(|edge| edge.id == *id)?;
if !edge.guard.is_empty() {
Some(edge.label)
} else {
let segment = edge
.points
.windows(2)
.nth(edge.points.len().saturating_sub(1) / 2)?;
let center = Point::new(
(segment[0].x + segment[1].x) / 2.,
(segment[0].y + segment[1].y) / 2.,
);
Some(Rectangle::new(
center - iced::Vector::new(16., 16.),
Size::new(32., 32.),
))
}
}
Target::ProcessPreviewLink {
relation,
source,
target,
} => {
let node = self
.nodes
.iter()
.find(|node| source.references.first() == Some(&node.id))?;
let link = node.preview_links.iter().find(|link| {
link.relation == *relation && link.source == *source && link.target == *target
})?;
let [a, b] = node.preview_link_visible_segment(link)?;
Some(Rectangle::new(
Point::new((a.x + b.x) / 2. - 16., (a.y + b.y) / 2. - 16.),
Size::new(32., 32.),
))
}
_ => None,
}
}
}
impl State {
/// Inactive owners use the same default preview policy as entering their canvas.
fn usage_graph<'a>(&self, p: &'a Project, owner: Id) -> Graph<'a> {
if self.identity.is_some_and(|(_, current)| current == owner) {
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded)
} else {
Graph::new(p, owner, owner)
}
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !owner_exists(p, owner)
|| !(p.elements.contains_key(&subject) || p.relations.contains_key(&subject))
{
return vec![];
}
let graph = self.usage_graph(p, owner);
let mut usages = vec![];
let mut add = |target: Target, role: &str| {
if let Some(bounds) = graph.usage_bounds(&target) {
usages.push(Usage {
subject,
surface: Surface::Process(owner),
target,
role: role.into(),
bounds,
});
}
};
if subject == owner {
add(Target::Whole, "process / chain canvas");
}
for node in &graph.nodes {
if let Some(role) = occurrence_role(p, node.id, subject) {
add(
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}),
role,
);
}
for (endpoint, _) in &node.preview {
if let Some(role) = occurrence_role(p, endpoint.occurrence, subject) {
add(
Target::ProcessOccurrence(endpoint.clone()),
&format!("reused {role}"),
);
}
}
for link in &node.preview_links {
if link.relation == subject {
add(
Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
},
"reused sequence link",
);
}
}
}
for edge in &graph.edges {
let role = if edge.id == subject {
Some("process link")
} else {
match p.relations.get(&edge.id).map(|r| &r.body) {
Some(RelationBody::ContextualLink {
purpose: ContextualLinkPurpose::Exchange { exchange },
..
}) if *exchange == subject => Some("assembly exchange"),
Some(RelationBody::ContextualLink {
purpose: ContextualLinkPurpose::CommonWork { work },
..
}) if *work == subject => Some("common work"),
_ => None,
}
};
if let Some(role) = role {
add(Target::ProcessLink(edge.id), role);
}
}
usages
}
/// Returns success, not whether the camera changed. App must separately guard
/// its other drafts and revalidate the queried subject's current usage.
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if !owner_exists(p, owner) || self.draft.is_some() {
return false;
}
let same = self.identity == Some((document, owner));
let graph = if same {
self.usage_graph(p, owner)
} else {
Graph::new(p, owner, owner)
};
let Some(bounds) = graph.usage_bounds(target) else {
return false;
};
let address = if let Target::ProcessOccurrence(endpoint) = target {
let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(p).resolve(owner, endpoint)
else {
return false;
};
(!endpoint.references.is_empty()).then_some(resolved.address)
} else {
None
};
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
if !same {
self.contextual_expanded.clear();
}
self.selected_context = address;
self.tool = CanvasTool::Select;
self.viewport = Some(viewport);
let fit = ((viewport.width - 48.).max(1.) / bounds.width.max(1.))
.min((viewport.height - 48.).max(1.) / bounds.height.max(1.));
let scale = if matches!(target, Target::Whole) {
fit.clamp(0.0001, 1.)
} else {
fit.clamp(0.6, 1.25)
};
self.zoom = Some(scale);
self.fit = false;
self.pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * scale,
viewport.height / 2. - bounds.center_y() * scale,
);
true
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,439 @@
use super::*;
fn add(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let e = occurrence(p, owner, name, body);
let id = e.id;
*p = execute_batch(p, &[Command::CreateElement(e)]).unwrap();
id
}
fn reused_fixture() -> (Project, Id, Id, Id, [Id; 2], Vec<Id>) {
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut p = f.p;
let sequence = p
.make_element(Kind::Process, Layer::Oa, "Reviewed checks", None)
.unwrap();
let sequence_id = sequence.id;
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
let mut leaves = vec![];
for name in [
"Check one",
"Check two",
"Check three",
"Check four",
"Check five",
] {
leaves.push(add(
&mut p,
sequence_id,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
));
}
let first = add(
&mut p,
f.owner,
"First invocation",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let second = add(
&mut p,
f.owner,
"Second invocation",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
(p, f.owner, sequence_id, f.work[0], [first, second], leaves)
}
#[test]
fn process_usages_exclude_endpoint_coincidence_and_keep_actual_occurrences_and_links() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let state = State::default();
// Both exchange endpoints have Action contributions, but there is no
// Exchange occurrence and no exchange arrow in this process.
assert!(state.diagram_usages(&p, f.owner, f.exchange).is_empty());
let direct = state.diagram_usages(&p, f.owner, f.work[0]);
assert_eq!(direct.len(), 1);
assert_eq!(
direct[0].target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: f.nodes[0]
})
);
let link = state.diagram_usages(&p, f.owner, f.link);
assert_eq!(link.len(), 1);
assert_eq!(link[0].target, Target::ProcessLink(f.link));
let first = add(
&mut p,
f.owner,
"First exchange",
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
let second = add(
&mut p,
f.owner,
"Second exchange",
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
let usages = state.diagram_usages(&p, f.owner, f.exchange);
assert_eq!(usages.len(), 2);
assert_eq!(
usages
.iter()
.map(|usage| usage.target.clone())
.collect::<Vec<_>>(),
vec![
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: first
}),
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: second
}),
]
);
}
}
#[test]
fn process_usages_keep_caller_paths_and_exclude_unshown_fifth_preview() {
let (p, owner, sequence, work, calls, leaves) = reused_fixture();
let before = p.clone();
let mut state = State::default();
let leaf = state.diagram_usages(&p, owner, leaves[0]);
assert_eq!(leaf.len(), 2);
for (usage, call) in leaf.iter().zip(calls) {
assert_eq!(
usage.target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[0]
})
);
}
assert_ne!(leaf[0].bounds, leaf[1].bounds);
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
assert_eq!(state.diagram_usages(&p, owner, sequence).len(), 2);
// One local action and four tiles in each of two caller boxes.
assert_eq!(state.diagram_usages(&p, owner, work).len(), 9);
state.enter(Id::new_v4(), owner);
assert!(state.expand(&p, owner, calls[0]));
let fifth = state.diagram_usages(&p, owner, leaves[4]);
assert_eq!(fifth.len(), 1);
assert_eq!(
fifth[0].target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[4]
})
);
// Another active owner must not leak its expansion to this queried canvas.
state.enter(Id::new_v4(), sequence);
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
assert_eq!(p, before);
}
#[test]
fn process_usage_reveal_validates_before_state_changes_and_centers_the_exact_tile() {
let (p, owner, _, _, calls, leaves) = reused_fixture();
let before = p.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter(document, owner);
state.viewport(Size::new(880., 460.));
state.pan = iced::Vector::new(-3000., -900.);
let hidden = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[4],
});
let prior = (
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone(),
);
assert!(!state.reveal_usage(&p, document, owner, &hidden));
assert_eq!(
(
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone()
),
prior
);
assert!(!state.reveal_usage(&p, document, Id::new_v4(), &Target::Whole));
assert_eq!(
(
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone()
),
prior
);
let endpoint = OccurrenceEndpoint {
references: vec![calls[1]],
occurrence: leaves[0],
};
let target = Target::ProcessOccurrence(endpoint.clone());
assert!(state.reveal_usage(&p, document, owner, &target));
assert_eq!(state.selected_context.as_ref().unwrap().endpoint, endpoint);
let bounds = state
.diagram_usages(&p, owner, leaves[0])
.into_iter()
.find(|usage| usage.target == target)
.unwrap()
.bounds;
let scale = state.zoom.unwrap();
assert!(scale >= 0.6);
assert!((bounds.center_x() * scale + state.pan.x - 440.).abs() < 0.01);
assert!((bounds.center_y() * scale + state.pan.y - 230.).abs() < 0.01);
assert!(state.reveal_usage(&p, document, owner, &Target::Whole));
assert!(state.selected_context.is_none());
state
.update(&p, Event::Start(owner, Tool::Work, None))
.unwrap();
let draft = state.recovery_snapshot().unwrap();
let camera = (state.pan, state.zoom, state.fit);
assert!(!state.reveal_usage(&p, document, owner, &target));
assert_eq!(state.recovery_snapshot().unwrap(), draft);
assert_eq!((state.pan, state.zoom, state.fit), camera);
assert_eq!(p, before);
}
#[test]
fn process_contextual_purpose_and_painted_preview_links_keep_distinct_identities() {
let (mut p, owner, sequence, work, calls, leaves) = reused_fixture();
let source = OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[0],
};
let target = OccurrenceEndpoint {
references: vec![calls[1]],
occurrence: leaves[0],
};
let relation = Relation {
id: Id::new_v4(),
alias: "same_check".into(),
name: "One common check".into(),
description: String::new(),
body: RelationBody::ContextualLink {
owner,
source,
target,
purpose: ContextualLinkPurpose::CommonWork { work },
},
};
let contextual = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let local_link = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let state = State::default();
assert!(
state
.diagram_usages(&p, owner, work)
.iter()
.any(|usage| usage.target == Target::ProcessLink(contextual)
&& usage.role == "common work")
);
assert_eq!(state.diagram_usages(&p, owner, contextual).len(), 1);
let previews = state.diagram_usages(&p, owner, local_link);
assert_eq!(previews.len(), 2);
for (usage, call) in previews.iter().zip(calls) {
assert_eq!(
usage.target,
Target::ProcessPreviewLink {
relation: local_link,
source: OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[0]
},
target: OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[1]
},
}
);
}
assert_eq!(state.diagram_usages(&p, sequence, local_link).len(), 1);
}
#[test]
fn preview_arrow_focus_is_in_the_painted_gap_and_hidden_endpoints_never_fallback() {
let (mut p, owner, _, _, calls, leaves) = reused_fixture();
let Command::CreateRelation(shown) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let shown_id = shown.id;
p = execute_batch(&p, &[Command::CreateRelation(shown)]).unwrap();
let Command::CreateRelation(hidden) = link(&p, leaves[0], leaves[4], "later") else {
unreachable!()
};
let hidden_id = hidden.id;
p = execute_batch(&p, &[Command::CreateRelation(hidden)]).unwrap();
let before = p.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter(document, owner);
state.viewport(Size::new(800., 460.));
let rows = state.diagram_usages(&p, owner, shown_id);
assert_eq!(rows.len(), 2);
let graph = Graph::new(&p, owner, owner);
for (row, call) in rows.iter().zip(calls) {
let node = graph.nodes.iter().find(|n| n.id == call).unwrap();
// The painter places 113px tiles 125px apart, beginning 12px in.
// The 12px gap is centered 131px into the caller, 134px below its top.
assert_eq!(
row.bounds.center(),
Point::new(node.rect.x + 131., node.rect.y + 134.)
);
assert!(
node.preview
.iter()
.all(|(e, _)| !node.endpoint_rect(e).contains(row.bounds.center()))
);
}
assert_ne!(rows[0].bounds, rows[1].bounds);
assert!(state.diagram_usages(&p, owner, hidden_id).is_empty());
assert!(state.reveal_usage(&p, document, owner, &rows[1].target));
assert!(
state.selected_context.is_none(),
"an arrow is not an occurrence authoring context"
);
let zoom = state.zoom.unwrap();
assert!((rows[1].bounds.center_x() * zoom + state.pan.x - 400.).abs() < 0.01);
assert!((rows[1].bounds.center_y() * zoom + state.pan.y - 230.).abs() < 0.01);
assert!(state.expand(&p, owner, calls[0]));
let expanded = state.diagram_usages(&p, owner, hidden_id);
assert_eq!(expanded.len(), 1);
assert!(
matches!(&expanded[0].target, Target::ProcessPreviewLink { source, .. }
if source.references == vec![calls[0]])
);
let mut stale = rows[1].target.clone();
if let Target::ProcessPreviewLink { source, .. } = &mut stale {
source.references = vec![Id::new_v4()];
}
let prior = (state.pan, state.zoom, state.fit);
assert!(!state.reveal_usage(&p, document, owner, &stale));
assert_eq!((state.pan, state.zoom, state.fit), prior);
assert_eq!(p, before);
}
#[test]
fn visible_preview_segment_excludes_all_opaque_tiles_for_reverse_and_cross_row_arrows() {
let tiles = [
Rectangle::new(Point::new(12., 118.), Size::new(113., 32.)),
Rectangle::new(Point::new(137., 118.), Size::new(113., 32.)),
Rectangle::new(Point::new(12., 162.), Size::new(113., 32.)),
Rectangle::new(Point::new(137., 162.), Size::new(113., 32.)),
];
for points in [
[Point::new(250., 134.), Point::new(12., 134.)],
[Point::new(125., 134.), Point::new(137., 178.)],
[Point::new(250., 178.), Point::new(12., 134.)],
] {
let [a, b] = visible_preview_segment(points, tiles.into_iter()).unwrap();
for fraction in [0.1, 0.5, 0.9] {
let point = a + (b - a) * fraction;
assert!(tiles.iter().all(|tile| !tile.contains(point)));
}
}
assert!(
visible_preview_segment(
[Point::new(30., 134.), Point::new(100., 134.)],
tiles.into_iter()
)
.is_none()
);
assert!(
visible_preview_segment([Point::new(1., 1.), Point::new(1., 1.)], tiles.into_iter())
.is_none()
);
}
#[test]
fn expanded_nested_tiles_do_not_invent_links_the_preview_painter_does_not_emit() {
let (mut p, owner, sequence, work, calls, _) = reused_fixture();
let nested = p
.make_element(Kind::Process, Layer::Oa, "Nested checks", None)
.unwrap();
let nested_id = nested.id;
p = execute_batch(&p, &[Command::CreateElement(nested)]).unwrap();
let leaves: Vec<_> = ["Nested one", "Nested two"]
.into_iter()
.map(|name| {
add(
&mut p,
nested_id,
name,
OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
)
})
.collect();
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let relation_id = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let reference = add(
&mut p,
sequence,
"Nested invocation",
OccurrenceBody::SequenceReference {
sequence: Some(nested_id),
},
);
let mut state = State::default();
state.enter(Id::new_v4(), owner);
state.expanded.insert(calls[0]);
state.contextual_expanded.insert(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: reference,
});
let shown = state.diagram_usages(&p, owner, leaves[0]);
assert_eq!(shown.len(), 1);
assert!(
matches!(&shown[0].target, Target::ProcessOccurrence(endpoint)
if endpoint.references == vec![calls[0], reference])
);
assert!(state.diagram_usages(&p, owner, relation_id).is_empty());
assert_eq!(state.diagram_usages(&p, nested_id, relation_id).len(), 1);
}
@@ -183,6 +183,32 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
&state.expanded,
&state.contextual_expanded,
);
graph.selected_preview = state
.qualified_selection(p, owner)
.filter(|address| {
address
.endpoint
.references
.first()
.copied()
.unwrap_or(address.endpoint.occurrence)
== app.selected
})
.map(|address| address.endpoint.clone());
graph.selected_preview_link = app
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
.and_then(|usage| match &usage.target {
crate::diagram_usage::Target::ProcessPreviewLink {
relation,
source,
target,
} => Some(PreviewLink {
relation: *relation,
source: source.clone(),
target: target.clone(),
}),
_ => None,
});
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.camera_pan = state.pan;
graph.pending_insertion = state
+20
View File
@@ -41,6 +41,8 @@ pub struct DiagramCanvas<'a> {
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
pub diagram: Id,
pub selected: Id,
/// Presentation-only navigation marker; never used by hit tests or edits.
pub navigation_focus: Option<Rectangle>,
pub zoom: f32,
pub pan: Vector,
pub context: Context,
@@ -1193,6 +1195,14 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.fill(&Path::circle(center, 1.5), color);
}
}
if let Some(bounds) = self.navigation_focus {
f.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2.5 / self.zoom),
);
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
{
@@ -1902,6 +1912,7 @@ mod tests {
connection: None,
diagram,
selected: p.id,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
};
@@ -1976,6 +1987,7 @@ mod tests {
connection: None,
diagram,
selected: p.id,
navigation_focus: None,
zoom: 0.6,
pan: Vector::new(10., 20.),
};
@@ -2092,6 +2104,7 @@ mod tests {
diagram,
context,
selected: p.id,
navigation_focus: None,
selected_bend: None,
zoom,
pan: Vector::new(20., 30.),
@@ -2259,6 +2272,7 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom,
pan: Vector::new(150., 300.),
context: Context {
@@ -2334,6 +2348,7 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 0.5,
pan: Vector::new(150., 300.),
context: Context {
@@ -2430,6 +2445,7 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 1.,
pan: Vector::new(150., 300.),
context: Context {
@@ -2557,6 +2573,7 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 0.7,
pan: Vector::new(350., 350.),
context: Context {
@@ -2610,6 +2627,7 @@ mod tests {
view_index: None,
diagram,
selected: p.id,
navigation_focus: None,
zoom: 1.,
pan: Vector::new(100., 400.),
context: Context {
@@ -2683,6 +2701,7 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 1.,
pan: Vector::new(100., 400.),
context: Context {
@@ -2715,6 +2734,7 @@ mod tests {
view_index: None,
diagram,
selected,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
context: Context {
@@ -57,6 +57,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: 1.,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -65,6 +65,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -6,6 +6,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
view_index: None,
diagram,
selected: Id::nil(),
navigation_focus: None,
zoom,
pan: Vector::new(17., 23.),
context: Context {
@@ -178,6 +178,7 @@ mod tests {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
context: app.route_context().unwrap(),
+472
View File
@@ -0,0 +1,472 @@
//! Read-only identities for objects actually represented by a diagram renderer.
use iced::Rectangle;
use oa_core::{Id, OccurrenceEndpoint, Project, projection, routing, view_options::ViewIndex};
pub(crate) mod view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Context {
pub document: Id,
pub revision: u64,
pub subject: Id,
}
#[derive(Debug, Clone)]
pub(crate) enum Event {
Open(Context),
Close(Id),
Search(Id, String),
Page(Id, usize),
Destination(Id, Option<Surface>),
Choose(Id, Usage),
Move(Id, i8),
Activate(Id),
}
/// One completed immutable accepted-model lookup. The token owns one worker slot.
#[derive(Debug, Clone)]
pub(crate) struct Completion {
pub token: Id,
pub context: Context,
pub result: Result<Vec<Usage>, String>,
}
pub(crate) struct Panel {
pub token: Id,
pub context: Context,
pub usages: Vec<Usage>,
pub loading: bool,
pub search: String,
pub page: usize,
pub cursor: usize,
pub destination: Option<Surface>,
pub notice: Option<String>,
}
impl Panel {
pub fn new(context: Context, usages: Vec<Usage>) -> Self {
Self {
token: Id::new_v4(),
context,
usages,
loading: false,
search: String::new(),
page: 0,
cursor: 0,
destination: None,
notice: None,
}
}
pub fn matches<'a>(&'a self, p: &Project) -> Vec<&'a Usage> {
let query = self.search.to_lowercase();
if query.is_empty() {
return self.usages.iter().collect();
}
self.usages
.iter()
.filter(|usage| {
let surface = usage.surface;
let searchable = format!(
"{} {} {} {} {}",
surface.category(p),
p.named(surface.id()),
alias(p, surface.id()),
target_name(p, usage),
usage.role
);
searchable.to_lowercase().contains(&query)
})
.collect()
}
pub fn choices(&self, p: &Project) -> Vec<Event> {
if self.loading {
return Vec::new();
}
let matches = self.matches(p);
if let Some(surface) = self.destination {
matches
.into_iter()
.filter(|u| u.surface == surface)
.map(|u| Event::Choose(self.token, u.clone()))
.collect()
} else {
let mut seen = std::collections::BTreeSet::new();
matches
.into_iter()
.filter(|u| seen.insert(u.surface))
.map(|u| Event::Destination(self.token, Some(u.surface)))
.collect()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Surface {
Saved(Id),
Process(Id),
Scenario(Id),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Whole,
Placement(Id),
Port {
port: Id,
placement: Id,
},
Edge(Id),
StateRow {
placement: Id,
relation: Id,
},
AllocationGrouping {
relation: Id,
work_placement: Id,
performer_placement: Id,
},
ProcessOccurrence(OccurrenceEndpoint),
ProcessLink(Id),
ProcessPreviewLink {
relation: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
},
ScenarioObject(Id),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Usage {
pub subject: Id,
pub surface: Surface,
pub target: Target,
/// Concise, truthful explanation, e.g. "action occurrence" or "entry action".
pub role: String,
/// Actual current renderer coordinates. Recompute before activation.
pub bounds: Rectangle,
}
/// A navigation highlight is presentation state, never an edit/Delete target.
pub(crate) struct Focus {
pub context: Context,
pub usage: Usage,
pub selected: Id,
}
impl Surface {
pub fn id(self) -> Id {
match self {
Self::Saved(id) | Self::Process(id) | Self::Scenario(id) => id,
}
}
pub fn category(self, p: &Project) -> &'static str {
match self {
Self::Saved(id)
if p.diagrams
.get(&id)
.is_some_and(|d| d.projection.state_machine().is_some()) =>
{
"State views"
}
Self::Saved(_) => "Architecture",
Self::Process(_) => "Processes and chains",
Self::Scenario(_) => "Scenarios",
}
}
}
pub(crate) fn collect(
p: &Project,
subject: Id,
process: &crate::behavior::topology::State,
scenario: &crate::behavior::scenario::State,
) -> Vec<Usage> {
let mut usages = saved_usages(p, subject);
for element in p.elements.values() {
match element.kind() {
oa_core::Kind::Process | oa_core::Kind::FunctionalChain => {
usages.extend(process.diagram_usages(p, element.id, subject));
}
oa_core::Kind::Scenario => {
usages.extend(scenario.diagram_usages(p, element.id, subject));
}
_ => {}
}
}
usages.sort_by_cached_key(|u| {
(
u.surface.category(p),
p.named(u.surface.id()).to_lowercase(),
u.surface,
target_name(p, u).to_lowercase(),
)
});
usages
.dedup_by(|a, b| a.subject == b.subject && a.surface == b.surface && a.target == b.target);
usages
}
pub(crate) fn alias(p: &Project, id: Id) -> &str {
p.elements
.get(&id)
.map(|e| e.alias.as_str())
.or_else(|| p.relations.get(&id).map(|r| r.alias.as_str()))
.or_else(|| p.diagrams.get(&id).map(|d| d.alias.as_str()))
.or_else(|| p.traces.get(&id).map(|t| t.alias.as_str()))
.or_else(|| {
p.diagrams
.values()
.find_map(|d| d.nodes.get(&id).map(|n| n.alias.as_str()))
})
.unwrap_or("")
}
pub(crate) fn target_name(p: &Project, usage: &Usage) -> String {
let id = match &usage.target {
Target::Whole => usage.surface.id(),
Target::Placement(id) => p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
.map_or(*id, |n| n.element),
Target::Port { port, .. } => *port,
Target::Edge(id)
| Target::StateRow { relation: id, .. }
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessLink(id)
| Target::ScenarioObject(id) => *id,
Target::ProcessOccurrence(endpoint) => {
return endpoint
.references
.iter()
.copied()
.chain([endpoint.occurrence])
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
.collect::<Vec<_>>()
.join(" / ");
}
Target::ProcessPreviewLink {
relation, source, ..
} => {
return source
.references
.iter()
.copied()
.chain([*relation])
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
.collect::<Vec<_>>()
.join(" / ");
}
};
format!("{} (@{})", p.named(id), alias(p, id))
}
/// Saved view membership is the renderer's final projection, including folds,
/// projection-kind exclusions and explicit view filters. Stored route metadata
/// and merely having both relation endpoints placed do not imply visibility.
pub(crate) fn saved_usages(p: &Project, subject: Id) -> Vec<Usage> {
saved_usages_for(p, subject, None)
}
/// Revalidate only the requested saved surface before activation. The optional
/// filter is applied before projection and routing, never after a whole lookup.
pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id>) -> Vec<Usage> {
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
return Vec::new();
}
let index = ViewIndex::new(p);
let mut usages = Vec::new();
for diagram in p
.diagrams
.values()
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
{
let view = projection::project_diagram_with_index(p, diagram, &index);
let surface = Surface::Saved(diagram.id);
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
usages.push(Usage {
subject,
surface,
target: Target::Whole,
role: if subject == diagram.id {
"diagram"
} else {
"state machine view"
}
.into(),
bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds),
});
}
for node in &view.nodes {
if subject == node.element || subject == node.id {
usages.push(Usage {
subject,
surface,
target: Target::Placement(node.id),
role: "node".into(),
bounds: Rectangle {
x: node.position.x,
y: node.position.y,
// Reveal the labelled header, not the empty interior of
// a large expanded container at the minimum zoom.
width: node.size.width.min(420.),
height: node.size.height.min(64.),
},
});
}
}
for port in &view.ports {
if subject == port.element {
usages.push(Usage {
subject,
surface,
target: Target::Port {
port: port.element,
placement: port.owner,
},
role: "port".into(),
bounds: Rectangle {
x: port.position.x - 12.,
y: port.position.y - 12.,
width: 24.,
height: 24.,
},
});
}
}
// Allocation arrows are suppressed in this projection. A direct
// relation is represented only by its actual visible containment chain;
// aggregate summaries never manufacture inherited relations.
if diagram.projection == oa_core::ProjectionKind::Allocation
&& let Some(relation) = p.relations.get(&subject)
&& relation.kind() == oa_core::RelationKind::Allocation
{
let placements: std::collections::BTreeMap<_, _> =
view.nodes.iter().map(|node| (node.id, node)).collect();
for work in view.nodes.iter().filter(|n| n.element == relation.target()) {
let mut parent = work.visual_parent;
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = parent.filter(|id| seen.insert(*id)) {
let Some(performer) = placements.get(&id) else {
break;
};
if performer.element == relation.source() {
usages.push(Usage {
subject,
surface,
target: Target::AllocationGrouping {
relation: subject,
work_placement: work.id,
performer_placement: performer.id,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: work.position.x,
y: work.position.y,
width: work.size.width.min(420.),
height: work.size.height.min(64.),
},
});
break;
}
parent = performer.visual_parent;
}
}
}
if view.edges.iter().any(|edge| edge.id == subject)
&& let Some(edge) = routing::route_diagram(p, diagram, &view)
.edges
.get(&subject)
{
usages.push(Usage {
subject,
surface,
target: Target::Edge(subject),
role: "relationship".into(),
bounds: edge_focus_bounds(edge),
});
}
if diagram.projection.state_machine().is_some() {
usages.extend(crate::behavior::state_canvas::diagram_usages(
p, diagram, &view, subject,
));
}
}
usages
}
pub(crate) fn routed_bounds(bounds: routing::RoutingBounds) -> Rectangle {
Rectangle {
x: bounds.position.x,
y: bounds.position.y,
width: bounds.size.width,
height: bounds.size.height,
}
}
/// Focus a named edge at its label. For an unlabelled line, focus its middle
/// segment instead of shrinking a long connector to fit the whole diagram.
pub(crate) fn edge_focus_bounds(edge: &routing::RoutedEdge) -> Rectangle {
if !edge.label.text.is_empty() {
return routed_bounds(edge.label.bounds);
}
let point = edge
.points
.get(edge.points.len() / 2)
.copied()
.unwrap_or(edge.label.center);
Rectangle {
x: point.x - 40.,
y: point.y - 24.,
width: 80.,
height: 48.,
}
}
/// Whole-canvas navigation fits its entire extent; an individual target keeps
/// the readable minimum zoom of `camera` rather than shrinking unrelated space.
pub(crate) fn camera_for(
target: &Target,
bounds: Rectangle,
viewport: iced::Size,
) -> (f32, iced::Vector) {
if !matches!(target, Target::Whole) {
return camera(bounds, viewport);
}
let available = iced::Size::new(
(viewport.width - 64.).max(1.),
(viewport.height - 64.).max(1.),
);
let zoom = (available.width / bounds.width.max(1.))
.min(available.height / bounds.height.max(1.))
.clamp(0.0001, 1.25);
let pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * zoom,
viewport.height / 2. - bounds.center_y() * zoom,
);
(zoom, pan)
}
/// Return a readable, centered camera without modifying authored geometry.
pub(crate) fn camera(bounds: Rectangle, viewport: iced::Size) -> (f32, iced::Vector) {
let available = iced::Size::new(
(viewport.width - 64.).max(1.),
(viewport.height - 64.).max(1.),
);
let zoom = (available.width / bounds.width.max(1.))
.min(available.height / bounds.height.max(1.))
.clamp(0.5, 1.25);
let pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * zoom,
viewport.height / 2. - bounds.center_y() * zoom,
);
(zoom, pan)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod coverage_tests;
@@ -0,0 +1,386 @@
//! Independent allocation membership oracles. Expected targets and coordinates
//! come from handwritten fixtures, never from the usage predicate under test.
use super::{Surface, Target, Usage, saved_usages, saved_usages_for};
use iced::Rectangle;
use oa_core::{
Id, Kind, Layer, Position, Project, ProjectionKind, RelationKind, Size, projection, validation,
};
struct AllocationFixture {
project: Project,
diagram: Id,
performer: Id,
work: Id,
relation: Id,
performer_node: Id,
work_node: Id,
}
fn assert_valid(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(
errors.is_empty(),
"fixture must be structurally valid: {errors:?}"
);
}
fn fixture() -> AllocationFixture {
let mut project = Project::empty();
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Rescue team", None)
.unwrap();
let work = project
.add_element(Kind::Activity, Layer::Oa, "Dispatch", None)
.unwrap();
let relation = project.relate(RelationKind::Allocation, "Performs", performer, work);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let performer_node = project.place(diagram, performer, Position { x: 20., y: 40. });
let work_node = project.place(diagram, work, Position { x: 240., y: 320. });
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&work_node)
.unwrap()
.size = Size {
width: 320.,
height: 110.,
};
assert_valid(&project);
AllocationFixture {
project,
diagram,
performer,
work,
relation,
performer_node,
work_node,
}
}
fn expected(f: &AllocationFixture) -> Usage {
Usage {
subject: f.relation,
surface: Surface::Saved(f.diagram),
target: Target::AllocationGrouping {
relation: f.relation,
work_placement: f.work_node,
performer_placement: f.performer_node,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: 240.,
y: 320.,
width: 320.,
height: 64.,
},
}
}
/// Two semantic decomposition levels, both summaries of the one allocated leaf.
/// No Allocation relation targets either summary.
fn nest_under_summaries(f: &mut AllocationFixture) -> (Id, Id, Id, Id) {
let top = f
.project
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let middle = f
.project
.add_element(Kind::Activity, Layer::Oa, "Coordinate", Some(top))
.unwrap();
f.project.elements.get_mut(&f.work).unwrap().parent = Some(middle);
let top_node = f
.project
.place(f.diagram, top, Position { x: 70., y: 120. });
let middle_node = f
.project
.place(f.diagram, middle, Position { x: 130., y: 210. });
assert_valid(&f.project);
(top, middle, top_node, middle_node)
}
#[test]
fn allocation_grouping_has_exact_relation_placements_and_readable_work_geometry() {
let f = fixture();
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
let work = view
.nodes
.iter()
.find(|node| node.id == f.work_node)
.unwrap();
assert_eq!(work.visual_parent, Some(f.performer_node));
assert_eq!(work.position, Position { x: 240., y: 320. });
assert_eq!(
work.size,
Size {
width: 320.,
height: 110.
}
);
assert!(!view.edges.iter().any(|edge| edge.id == f.relation));
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
assert_eq!(
saved_usages_for(&f.project, f.relation, Some(f.diagram)),
vec![expected(&f)]
);
assert!(saved_usages_for(&f.project, f.relation, Some(Id::new_v4())).is_empty());
// The semantic work and its placement remain ordinary node selections.
for subject in [f.work, f.work_node] {
let usages = saved_usages(&f.project, subject);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].target, Target::Placement(f.work_node));
}
assert_eq!(f.project, before);
}
#[test]
fn allocation_grouping_walks_two_visible_summary_levels_without_inventing_allocations() {
let mut f = fixture();
let (top, middle, top_node, middle_node) = nest_under_summaries(&mut f);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
for (node_id, parent, summary) in [
(f.work_node, Some(middle_node), false),
(middle_node, Some(top_node), true),
(top_node, Some(f.performer_node), true),
] {
let node = view.nodes.iter().find(|node| node.id == node_id).unwrap();
assert_eq!(node.visual_parent, parent);
assert_eq!(node.allocation_summary, summary);
}
assert_eq!(f.project.relations.len(), 1);
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
for (element, node) in [(top, top_node), (middle, middle_node)] {
let usages = saved_usages(&f.project, element);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].target, Target::Placement(node));
}
assert_eq!(f.project, before);
}
#[test]
fn folded_summary_or_performer_does_not_substitute_a_visible_ancestor_for_the_leaf() {
let mut f = fixture();
let (_, _, top_node, middle_node) = nest_under_summaries(&mut f);
for folded in [f.performer_node, top_node, middle_node] {
let mut project = f.project.clone();
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&folded)
.unwrap()
.collapsed = true;
assert_valid(&project);
let before = project.clone();
let view = projection::project_view(&project, f.diagram);
assert!(view.nodes.iter().any(|node| node.id == folded));
assert!(!view.nodes.iter().any(|node| node.id == f.work_node));
assert!(saved_usages(&project, f.relation).is_empty());
assert_eq!(project, before);
}
}
#[test]
fn absent_endpoint_placements_and_hidden_performer_do_not_create_grouping_targets() {
let mut f = fixture();
let (_, _, top_node, _) = nest_under_summaries(&mut f);
for absent in [f.performer_node, f.work_node] {
let mut project = f.project.clone();
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&absent);
assert_valid(&project);
let before = project.clone();
let view = projection::project_view(&project, f.diagram);
// The aggregate card remains visible even when the actual leaf or its
// actual performer has no placement. It cannot stand in for that end.
assert!(
view.nodes
.iter()
.any(|node| node.id == top_node && node.allocation_summary)
);
assert!(saved_usages(&project, f.relation).is_empty());
assert_eq!(project, before);
}
let outer = f
.project
.add_element(Kind::Entity, Layer::Oa, "Organization", None)
.unwrap();
f.project.elements.get_mut(&f.performer).unwrap().parent = Some(outer);
let outer_node = f
.project
.place(f.diagram, outer, Position { x: -40., y: -60. });
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&outer_node)
.unwrap()
.collapsed = true;
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
assert!(view.nodes.iter().any(|node| node.id == outer_node));
assert!(!view.nodes.iter().any(|node| node.id == f.performer_node));
assert!(saved_usages(&f.project, f.relation).is_empty());
assert_eq!(f.project, before);
}
#[test]
fn collapsed_leaf_and_edge_filter_still_leave_the_actual_nesting_visible() {
let mut f = fixture();
let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap();
diagram.nodes.get_mut(&f.work_node).unwrap().collapsed = true;
// hidden_edges controls arrows. Allocation is drawn as nesting, which is
// still visible; do not mistake the filter for a hidden node/grouping.
diagram.view_options.hidden_edges.push(f.relation);
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
let work = view
.nodes
.iter()
.find(|node| node.id == f.work_node)
.unwrap();
assert!(work.collapsed);
assert_eq!(work.visual_parent, Some(f.performer_node));
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
assert_eq!(f.project, before);
}
#[test]
fn other_projections_do_not_report_allocation_nesting_when_only_an_arrow_or_nothing_is_drawn() {
let mut f = fixture();
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Overview;
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
assert!(view.edges.iter().any(|edge| edge.id == f.relation));
assert!(view.nodes.iter().all(|node| node.visual_parent.is_none()));
let usages = saved_usages(&f.project, f.relation);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].subject, f.relation);
assert_eq!(usages[0].surface, Surface::Saved(f.diagram));
assert_eq!(usages[0].target, Target::Edge(f.relation));
assert_eq!(f.project, before);
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
assert!(view.nodes.iter().any(|node| node.id == f.work_node));
assert!(!view.nodes.iter().any(|node| node.id == f.performer_node));
assert!(saved_usages(&f.project, f.relation).is_empty());
assert_eq!(f.project, before);
}
#[test]
fn repeated_saved_views_keep_allocation_placement_identity_and_geometry_independent() {
let mut f = fixture();
// The first diagram is Allocation, so this creates a different Overview.
let second = f.project.ensure_diagram(Layer::Oa);
assert_ne!(second, f.diagram);
f.project.diagrams.get_mut(&second).unwrap().projection = ProjectionKind::Allocation;
let second_performer = f
.project
.place(second, f.performer, Position { x: 900., y: 600. });
let second_work = f
.project
.place(second, f.work, Position { x: 1000., y: 700. });
f.project
.diagrams
.get_mut(&second)
.unwrap()
.nodes
.get_mut(&second_work)
.unwrap()
.size = Size {
width: 520.,
height: 130.,
};
assert_ne!(second_performer, f.performer_node);
assert_ne!(second_work, f.work_node);
assert_valid(&f.project);
let before = f.project.clone();
let other = Usage {
subject: f.relation,
surface: Surface::Saved(second),
target: Target::AllocationGrouping {
relation: f.relation,
work_placement: second_work,
performer_placement: second_performer,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: 1000.,
y: 700.,
width: 420.,
height: 64.,
},
};
let usages = saved_usages(&f.project, f.relation);
assert_eq!(usages.len(), 2);
assert!(usages.contains(&expected(&f)));
assert!(usages.contains(&other));
assert_eq!(
saved_usages_for(&f.project, f.relation, Some(second)),
vec![other]
);
assert_eq!(f.project, before);
}
#[test]
fn role_derived_nesting_without_a_direct_allocation_does_not_invent_one() {
let mut f = fixture();
f.project.relations.remove(&f.relation);
let role = f
.project
.add_element(Kind::Role, Layer::Oa, "Dispatcher", None)
.unwrap();
let realization = f.project.relate(
RelationKind::RoleRealization,
"Realizes dispatcher",
f.performer,
role,
);
let assignment = f
.project
.relate(RelationKind::RoleAssignment, "Dispatches", role, f.work);
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
let work = view
.nodes
.iter()
.find(|node| node.id == f.work_node)
.unwrap();
assert_eq!(work.visual_parent, Some(f.performer_node));
assert!(work.allocation_summary);
assert!(
f.project
.relations
.values()
.all(|relation| relation.kind() != RelationKind::Allocation)
);
for subject in [f.relation, realization, assignment] {
assert!(saved_usages(&f.project, subject).is_empty());
}
let usages = saved_usages(&f.project, f.work);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].target, Target::Placement(f.work_node));
assert_eq!(f.project, before);
}
@@ -0,0 +1,196 @@
use super::*;
use oa_core::{Kind, Layer, Position, RelationKind};
#[test]
fn saved_membership_respects_filters_and_does_not_change_project() {
let mut p = Project::empty();
let first = p.add(Kind::Activity, "Same name", 20., 20.);
let second = p.add(Kind::Activity, "Same name", 340., 20.);
let relation = p.relate(RelationKind::Interaction, "Handoff", first, second);
let shown = p.ensure_diagram(Layer::Oa);
let mut hidden = p.diagrams[&shown].clone();
hidden.id = Id::new_v4();
hidden.alias = "filtered_copy".into();
hidden.nodes = hidden
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("filtered_{}", node.alias);
(node.id, node)
})
.collect();
hidden.view_options.hidden_edges.push(relation);
let hidden_id = hidden.id;
p.diagrams.insert(hidden_id, hidden);
let before = p.clone();
let hits = saved_usages(&p, relation);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].surface, Surface::Saved(shown));
assert_eq!(hits[0].target, Target::Edge(relation));
let hits = saved_usages(&p, first);
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|hit| match hit.target {
Target::Placement(node) => p.diagrams[&hit.surface.id()].nodes[&node].element == first,
_ => false,
}));
assert_eq!(p, before);
}
#[test]
fn folded_child_is_not_a_visible_node_and_placement_identity_stays_exact() {
let mut p = Project::empty();
let parent = p.add(Kind::Activity, "Container", 20., 20.);
let child = p
.add_element(Kind::Activity, Layer::Oa, "Nested", Some(parent))
.unwrap();
let diagram = p.ensure_diagram(Layer::Oa);
let child_node = p.place(diagram, child, Position { x: 50., y: 100. });
let parent_node = p.diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == parent)
.unwrap()
.id;
let hits = saved_usages(&p, child_node);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].target, Target::Placement(child_node));
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&parent_node)
.unwrap()
.collapsed = true;
let before = p.clone();
assert!(saved_usages(&p, child).is_empty());
assert!(saved_usages(&p, child_node).is_empty());
assert_eq!(saved_usages(&p, parent).len(), 1);
assert_eq!(p, before);
}
#[test]
fn ports_are_found_at_the_visible_owner_and_unplaced_elements_do_not_match_names() {
let mut p = Project::empty();
let owner = p.add(Kind::Function, "Compute", 20., 20.);
let port = Id::new_v4();
let element = oa_core::ports::make_port(
&p,
port,
owner,
"Input",
oa_core::PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap();
p = oa_core::execute_batch(&p, &[oa_core::Command::CreateElement(element)]).unwrap();
let unrelated = p
.add_element(Kind::Function, Layer::Sa, "Compute", None)
.unwrap();
let hits = saved_usages(&p, port);
assert_eq!(hits.len(), 1);
let view = projection::project_view(&p, hits[0].surface.id());
let painted = view
.ports
.iter()
.find(|shown| shown.element == port)
.unwrap();
assert_eq!(
hits[0].target,
Target::Port {
port,
placement: painted.owner
}
);
assert_eq!(
hits[0].bounds.center(),
iced::Point::new(painted.position.x, painted.position.y)
);
assert!(saved_usages(&p, unrelated).is_empty());
assert!(saved_usages(&p, Id::new_v4()).is_empty());
}
#[test]
fn reveal_camera_centers_target_without_whole_view_shrink() {
let bounds = Rectangle {
x: 100_000.,
y: -50_000.,
width: 224.,
height: 94.,
};
let viewport = iced::Size::new(700., 500.);
let (zoom, pan) = camera(bounds, viewport);
assert_eq!(zoom, 1.25);
assert!((bounds.center_x() * zoom + pan.x - 350.).abs() < 0.01);
assert!((bounds.center_y() * zoom + pan.y - 250.).abs() < 0.01);
}
#[test]
fn oversized_container_reveal_targets_its_labelled_header() {
let mut p = Project::empty();
let container = p.add(Kind::Activity, "Large enclosing activity", 40., 70.);
let diagram = p.ensure_diagram(Layer::Oa);
let node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.values_mut()
.next()
.unwrap();
node.size = oa_core::Size {
width: 4000.,
height: 4000.,
};
let before = p.clone();
let hits = saved_usages(&p, container);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].bounds.x, 40.);
assert_eq!(hits[0].bounds.y, 70.);
assert!(hits[0].bounds.width <= 420.);
assert!(hits[0].bounds.height <= 64.);
let (zoom, pan) = camera(hits[0].bounds, iced::Size::new(500., 300.));
assert!((hits[0].bounds.center_x() * zoom + pan.x - 250.).abs() < 0.01);
assert!(70. * zoom + pan.y >= 0.);
assert_eq!(p, before);
}
#[test]
fn state_machine_owner_finds_its_view_without_a_machine_placement() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Monitor", None)
.unwrap();
let unrelated = p
.add_element(Kind::StateMachine, Layer::Sa, "Other", None)
.unwrap();
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection =
oa_core::ProjectionKind::StateMachine { machine };
let before = p.clone();
let hits = saved_usages(&p, machine);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].surface, Surface::Saved(diagram));
assert_eq!(hits[0].target, Target::Whole);
assert_eq!(hits[0].role, "state machine view");
assert!(saved_usages(&p, unrelated).is_empty());
assert_eq!(p, before);
}
#[test]
fn whole_diagram_camera_fits_large_bounds_instead_of_individual_object_scale() {
let bounds = Rectangle {
x: 2000.,
y: -4000.,
width: 100_000.,
height: 80_000.,
};
let (zoom, pan) = camera_for(&Target::Whole, bounds, iced::Size::new(500., 300.));
assert!(zoom < 0.01);
assert!(bounds.x * zoom + pan.x >= 0.);
assert!(bounds.y * zoom + pan.y >= 0.);
assert!((bounds.x + bounds.width) * zoom + pan.x <= 500.);
assert!((bounds.y + bounds.height) * zoom + pan.y <= 300.);
}
+220
View File
@@ -0,0 +1,220 @@
use super::*;
use crate::{
app::{App, Message},
style,
views::{action, short},
};
use iced::{
Element, Fill,
widget::{self, column, container, row, text, text_input, tooltip},
};
use std::collections::BTreeSet;
const PAGE_SIZE: usize = 6;
pub(crate) fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
let Some(panel) = &app.diagram_usages else {
return base;
};
let p = app.project();
let token = panel.token;
let message = |event| Message::DiagramUsage(event);
let matched = panel.matches(p);
let mut surfaces = Vec::new();
let mut seen = BTreeSet::new();
for usage in &matched {
if seen.insert(usage.surface) {
surfaces.push(usage.surface);
}
}
let targets: Vec<_> = matched
.iter()
.copied()
.filter(|usage| Some(usage.surface) == panel.destination)
.collect();
let count = if panel.destination.is_some() {
targets.len()
} else {
surfaces.len()
};
let last = count.saturating_sub(1) / PAGE_SIZE;
let page = panel.page.min(last);
let total_surfaces = panel
.usages
.iter()
.map(|u| u.surface)
.collect::<BTreeSet<_>>()
.len();
let mut content = column![
row![
text("Show in diagrams").size(22).width(Fill),
action("Close", message(Event::Close(token)))
]
.spacing(12),
tooltip(
text(format!(
"{} · @{}",
short(p.named(panel.context.subject), 48),
short(alias(p, panel.context.subject), 48)
))
.size(14),
container(
widget::scrollable(
text(format!(
"{}\n@{}",
p.named(panel.context.subject),
alias(p, panel.context.subject)
))
.size(13)
)
.height(220)
)
.padding(10)
.width(620)
.style(style::card),
tooltip::Position::Bottom
),
text(if panel.loading {
"Searching rendered objects…".into()
} else {
format!(
"{} diagram destinations · {} representations",
total_surfaces,
panel.usages.len()
)
})
.size(12)
.color(style::MUTED),
text_input("Filter diagram, occurrence, or alias", &panel.search)
.id(format!("diagram-usage-filter-{}", panel.token))
.padding(9)
.on_input(move |value| Message::DiagramUsage(Event::Search(token, value)))
.on_submit(message(Event::Activate(token))),
]
.spacing(10);
if let Some(notice) = &panel.notice {
content = content.push(text(notice).size(13));
}
if let Some(surface) = panel.destination {
content = content.push(
row![
action("All diagrams", message(Event::Destination(token, None))),
text(short(p.named(surface.id()), 55)).size(15),
]
.spacing(10),
);
for (index, usage) in targets
.into_iter()
.enumerate()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
{
let name = target_name(p, usage);
content = content.push(tooltip(
action(
&format!("{} · {}", usage.role, short(&name, 70)),
message(Event::Choose(token, usage.clone())),
)
.width(Fill)
.style(if panel.cursor == index {
style::selected
} else {
style::nav
}),
container(text(name).size(13))
.padding(10)
.max_width(680)
.style(style::card),
tooltip::Position::Top,
));
}
} else {
for (index, surface) in surfaces
.into_iter()
.enumerate()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
{
let targets = matched.iter().filter(|u| u.surface == surface).count();
let name = format!(
"{} · {} (@{}) · {} representations",
surface.category(p),
p.named(surface.id()),
alias(p, surface.id()),
targets
);
let layer = p
.diagrams
.get(&surface.id())
.map(|d| d.layer)
.or_else(|| p.layer_of(surface.id()))
.map(|l| l.to_string())
.unwrap_or_default();
content = content.push(tooltip(
action(
&format!(
"{} · {} · {} · {}",
surface.category(p),
layer,
short(p.named(surface.id()), 34),
targets
),
message(Event::Destination(token, Some(surface))),
)
.width(Fill)
.style(if panel.cursor == index {
style::selected
} else {
style::nav
}),
container(text(name).size(13))
.padding(10)
.max_width(680)
.style(style::card),
tooltip::Position::Top,
));
}
}
if panel.loading {
content = content.push(text("Finding diagram representations…").size(13));
} else if count == 0 {
content = content.push(text(if panel.usages.is_empty() {
"No currently rendered representation found. Folded or filtered objects and unexpanded reuse contents are excluded."
} else { "No results match this filter." }).size(13));
}
let mut paging = row![
text(format!(
"Page {} of {} · {} matches",
page + 1,
last + 1,
count
))
.size(12)
.width(Fill)
]
.spacing(8);
if page > 0 {
paging = paging.push(action("Previous", message(Event::Page(token, page - 1))));
}
if page < last {
paging = paging.push(action("Next", message(Event::Page(token, page + 1))));
}
content = content.push(paging).push(text("↑ ↓ choose · Enter opens · Escape closes. Source and saved view settings are preserved.").size(12).color(style::MUTED));
widget::stack![
base,
widget::opaque(
container(
container(content)
.padding(22)
.width((app.window_size.width - 80.).clamp(620., 820.))
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
}
+17 -1
View File
@@ -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(&current);
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);
}
+22 -1
View File
@@ -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",
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",
@@ -818,10 +818,31 @@ fn article(topic: Topic) -> Article {
"Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
],
),
explain(
"Follow definitions and lexical references",
"Place the caret on a declaration alias or an @reference. Choose Definition (F12) to locate its declaration, or References (Shift+F12) to list lexical uses. Choose a row when a definition is ambiguous, draft or detached. Lexical uses exclude comments and strings; they do not establish typed relationships or resolve an ambiguous alias to one declaration. Back (Alt+Left) returns to the previous source location; if that draft changed, it restores the current cursor instead of an obsolete selection. These shortcuts work while the source editor has focus; the Text toolbar also offers the actions.",
),
explain(
"Read the navigation scope",
"Queries inspect bounded snapshots of currently loaded drafts, without applying them or loading missing included files. The panel identifies partial scope, draft evidence, detached or unverified files, and alias or ID collisions. Counts show total, displayed and omitted rows. A missing result in partial scope does not establish that the whole project has no match. If sources change, stale rows are disabled; run Definition or References again. Row locations use one-based lines and UTF-8 byte columns. Navigation preserves your source drafts and the accepted model.",
),
explain(
"Find the record in diagrams",
"Choose Show in diagrams from the Text toolbar on a unique verified alias in clean sources, or from the chosen model-tree record's context menu. Choose a diagram destination, then an exact representation. Results follow visible projection: ports, state rows, repeated caller tiles and their drawn sequence links remain distinct. Hidden tiles and nested links that are not drawn are excluded. A direct allocation can be represented by visible grouping; aggregate summaries do not invent allocation relations. A visual marker locates the port, row or grouped work while keeping the queried semantic record selected for model actions. Moving the camera preserves that marker. Finish pending edits first; navigation preserves accepted geometry, sources and history.",
),
explain(
"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.",
+2
View File
@@ -7,6 +7,7 @@ mod context_menu;
mod data_values;
mod data_view;
mod diagram;
mod diagram_usage;
mod document;
mod explorer;
mod explorer_view;
@@ -24,6 +25,7 @@ mod recovery;
mod route_edit;
mod semantic_review;
mod source_navigation;
mod source_rename;
mod source_tree;
mod source_workspace;
mod style;
+5
View File
@@ -281,6 +281,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
}
entries.push(item("Properties", Message::EditProperties(id), true));
entries.push(item("Open source", Message::SourceForSelection, true));
entries.push(item(
"Show in diagrams…",
app.diagram_usage_message(id),
true,
));
entries.push(item(
"Connections & references",
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
+4 -41
View File
@@ -126,28 +126,8 @@ fn groups(p: &Project, selected: Id) -> Vec<Group> {
]
}
pub fn diagrams_containing(p: &Project, selected: Id) -> Vec<Id> {
let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]);
p.diagrams
.values()
.filter(|d| {
let placed = |id| {
let e = p.elements.get(&id);
let owner = e
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id);
let kind = p.elements.get(&owner).map(|e| e.kind());
kind.is_none_or(|kind| d.projection.includes(kind))
&& d.nodes.values().any(|n| n.element == owner || n.id == id)
};
placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed))
})
.map(|d| d.id)
.collect()
}
pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
pub fn view(app: &crate::app::App, selected: Id) -> Element<'_, Message> {
let p = app.project();
let mut content = column![
text(short(p.named(selected), 50)).size(18),
text("Follow ownership, usage and engineering relationships.")
@@ -203,25 +183,8 @@ pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
content = content.push(container(entry).padding(8).style(style::subtle));
}
}
let diagrams = diagrams_containing(p, selected);
if !diagrams.is_empty() {
content = content.push(label("SHOWN IN DIAGRAMS"));
for id in diagrams {
content = content.push(
action(
&short(p.named(id), 42),
Message::RevealInDiagram(id, selected),
)
.width(Fill),
);
}
} else if p.elements.contains_key(&selected) {
content = content.push(
text("This element has no diagram placement yet.")
.size(12)
.color(style::MUTED),
);
}
content =
content.push(action("Show in diagrams…", app.diagram_usage_message(selected)).width(Fill));
if count == 0 {
content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED));
}
@@ -1,4 +1,9 @@
//! Literal source search and line navigation, independent of accepted model state.
mod controller;
pub mod interaction;
pub mod query;
pub mod symbols;
pub use controller::*;
use iced::widget::text_editor::{Cursor, Position};
#[derive(Debug, Clone)]
@@ -0,0 +1,126 @@
//! Ephemeral editor navigation; no model commands or filesystem operations.
use super::query;
use iced::widget::text_editor::{Cursor, Position};
use oa_core::Id;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
#[derive(Debug, Clone)]
pub enum SymbolEvent {
DiagramUsages,
Query(query::Action),
Back,
Close,
Choose {
stamp: query::QueryStamp,
reference: bool,
index: usize,
},
}
/// Identity of the mounted source view that emitted an action. Message queues
/// may outlive that view; request-time stamping alone cannot detect replay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolContext {
pub session: Id,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub session: Id,
pub path: String,
pub generation: u64,
pub cursor: Cursor,
}
#[derive(Debug, Clone)]
pub struct Request {
pub job: Id,
pub interaction: Id,
pub raw_interaction: u64,
pub stamp: query::QueryStamp,
pub path: String,
pub offset: usize,
pub cursor: Cursor,
pub action: query::Action,
}
#[derive(Debug, Clone)]
pub struct Completion {
pub request: Request,
pub result: Result<(Arc<query::Snapshot>, Arc<query::Results>), String>,
}
pub struct Reveal {
pub ticket: Id,
pub interaction: Id,
pub raw_interaction: u64,
pub snapshot: query::SnapshotKey,
pub path: String,
}
pub struct SymbolState {
pub open: bool,
pub interaction: Id,
pub raw_interaction: Arc<AtomicU64>,
pub serial: u64,
pub in_flight: Option<Id>,
pub request: Option<Request>,
pub cache: Option<Arc<query::Snapshot>>,
pub results: Option<Arc<query::Results>>,
pub notice: Option<String>,
pub back: Vec<HistoryEntry>,
pub reveal: Option<Reveal>,
}
impl Default for SymbolState {
fn default() -> Self {
Self {
open: false,
interaction: Id::new_v4(),
raw_interaction: Arc::new(AtomicU64::new(0)),
serial: 0,
in_flight: None,
request: None,
cache: None,
results: None,
notice: None,
back: Vec::new(),
reveal: None,
}
}
}
/// Iced uses zero-based UTF-8 byte columns. Reject stale or split-codepoint
/// coordinates; do not normalize CRLF or silently clamp query destinations.
pub fn byte_offset(text: &str, position: Position) -> Option<usize> {
let start = if position.line == 0 {
0
} else {
text.match_indices('\n').nth(position.line - 1)?.0 + 1
};
let end = text[start..]
.find('\n')
.map_or(text.len(), |end| start + end);
let offset = start.checked_add(position.column)?;
(offset <= end && text.is_char_boundary(offset)).then_some(offset)
}
pub fn cursor_offset(text: &str, cursor: Cursor) -> Option<usize> {
let position = cursor.selection.map_or(cursor.position, |selection| {
if (selection.line, selection.column) < (cursor.position.line, cursor.position.column) {
selection
} else {
cursor.position
}
});
byte_offset(text, position)
}
pub fn exact_cursor(text: &str, cursor: Cursor) -> bool {
byte_offset(text, cursor.position).is_some()
&& cursor
.selection
.is_none_or(|position| byte_offset(text, position).is_some())
}
@@ -0,0 +1,284 @@
//! Raw input invalidation and focus checks at widget-operation execution time.
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
use iced::{Element, Event, Length, Rectangle, Size, Task, Vector, keyboard, mouse, touch};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
/// Exhaustion fails closed: a saturated clock can never authorize navigation.
/// Replace the clock with a new workspace/controller, rather than wrapping it.
pub fn is_current(clock: &AtomicU64, expected: u64) -> bool {
expected != u64::MAX && clock.load(Ordering::Acquire) == expected
}
fn invalidate(clock: &AtomicU64, event: &Event) {
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { .. })
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
| Event::Touch(touch::Event::FingerPressed { .. })
| Event::Window(iced::window::Event::Unfocused)
) {
let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
Some(value.saturating_add(1))
});
}
}
/// Transparent, including overlays: invalidate before the child can capture input.
pub fn observe<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>(
content: Element<'a, Message, Theme, Renderer>,
clock: Arc<AtomicU64>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(Observed { content, clock })
}
struct Observed<'a, Message, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
clock: Arc<AtomicU64>,
}
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
for Observed<'_, Message, Theme, Renderer>
{
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> widget::tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<widget::Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut widget::Tree) {
self.content.as_widget().diff(tree);
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
invalidate(&self.clock, event);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let clock = self.clock.clone();
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
}
}
struct ObservedOverlay<'a, Message, Theme, Renderer> {
content: overlay::Element<'a, Message, Theme, Renderer>,
clock: Arc<AtomicU64>,
}
impl<Message, Theme, Renderer: renderer::Renderer> overlay::Overlay<Message, Theme, Renderer>
for ObservedOverlay<'_, Message, Theme, Renderer>
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.content.as_overlay_mut().layout(renderer, bounds)
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
self.content
.as_overlay()
.draw(renderer, theme, style, layout, cursor);
}
fn operate(
&mut self,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_overlay_mut()
.operate(layout, renderer, operation);
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
invalidate(&self.clock, event);
self.content
.as_overlay_mut()
.update(event, layout, cursor, renderer, clipboard, shell);
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_overlay()
.mouse_interaction(layout, cursor, renderer)
}
fn overlay<'a>(
&'a mut self,
layout: Layout<'a>,
renderer: &Renderer,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let clock = self.clock.clone();
self.content
.as_overlay_mut()
.overlay(layout, renderer)
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
}
fn index(&self) -> f32 {
self.content.as_overlay().index()
}
}
/// Inspect all focus owners, then focus only the mounted target. Iced executes
/// Operation::Chain synchronously, without an intervening input event. Both
/// traversals recheck the raw clock; no result message authorizes later focus.
pub fn guarded_focus<Message: Send + 'static>(
target: widget::Id,
clock: Arc<AtomicU64>,
expected: u64,
) -> Task<Message> {
widget::operate(GuardedFocus::new(target, clock, expected))
}
struct GuardedFocus {
target: widget::Id,
clock: Arc<AtomicU64>,
expected: u64,
target_count: usize,
foreign_focus: bool,
apply: bool,
}
impl GuardedFocus {
fn new(target: widget::Id, clock: Arc<AtomicU64>, expected: u64) -> Self {
Self {
target,
clock,
expected,
target_count: 0,
foreign_focus: false,
apply: false,
}
}
}
impl<Message: 'static> widget::Operation<Message> for GuardedFocus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation<Message>)) {
if is_current(&self.clock, self.expected) {
visit(self);
}
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
if !is_current(&self.clock, self.expected) {
return;
}
if id == Some(&self.target) {
self.target_count += 1;
if self.apply {
state.focus();
}
} else if state.is_focused() {
self.foreign_focus = true;
}
}
fn finish(&self) -> widget::operation::Outcome<Message> {
if !self.apply
&& self.target_count == 1
&& !self.foreign_focus
&& is_current(&self.clock, self.expected)
{
widget::operation::Outcome::Chain(Box::new(Self {
apply: true,
..Self::new(self.target.clone(), self.clock.clone(), self.expected)
}))
} else {
widget::operation::Outcome::None
}
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -0,0 +1,190 @@
use super::*;
use iced::advanced::widget::operation::{Focusable, Outcome};
use iced::widget::{column, text_input};
use iced::{Point, Theme};
#[derive(Default)]
struct FocusMap(Vec<(Option<widget::Id>, Rectangle, bool)>);
impl widget::Operation for FocusMap {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) {
self.0.push((id.cloned(), bounds, state.is_focused()));
}
}
struct Mounted {
element: Element<'static, (), Theme, ()>,
tree: widget::Tree,
node: layout::Node,
}
impl Mounted {
fn new(clock: Arc<AtomicU64>) -> Self {
let content = column![
text_input("Editor", "source")
.id("editor")
.on_input(|_| ())
.padding(12),
text_input("Find", "")
.id("find")
.on_input(|_| ())
.padding(12),
]
.spacing(10)
.into();
let mut element = observe(content, clock);
let mut tree = widget::Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 200.)),
);
Self {
element,
tree,
node,
}
}
fn pass(&mut self, operation: &mut dyn widget::Operation) {
self.element.as_widget_mut().operate(
&mut self.tree,
Layout::new(&self.node),
&(),
operation,
);
}
fn run(&mut self, operation: impl widget::Operation + 'static) {
let mut next: Box<dyn widget::Operation> = Box::new(operation);
loop {
self.pass(next.as_mut());
match next.finish() {
Outcome::Chain(chained) => next = chained,
Outcome::None => break,
Outcome::Some(()) => panic!("focus must not publish an application message"),
}
}
}
fn focus(&mut self) -> FocusMap {
let mut focus = FocusMap::default();
self.pass(&mut focus);
focus
}
fn click(&mut self, id: &str) {
let bounds = self
.focus()
.0
.into_iter()
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
.unwrap()
.1;
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
self.element.as_widget_mut().update(
&mut self.tree,
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&self.node),
mouse::Cursor::Available(bounds.center()),
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(Size::new(400., 200.)),
);
assert!(shell.is_event_captured());
assert!(
messages.is_empty(),
"focus-only click must not rely on App messages"
);
}
fn focused(&mut self, id: &str) -> bool {
self.focus()
.0
.into_iter()
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
.unwrap()
.2
}
}
#[test]
fn captured_find_click_invalidates_without_app_message_and_preserves_focus() {
let clock = Arc::new(AtomicU64::new(0));
let mut ui = Mounted::new(clock.clone());
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
assert!(ui.focused("editor"));
ui.click("find");
assert!(!is_current(&clock, 0));
assert!(ui.focused("find"));
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
assert!(ui.focused("find"));
// Even a matching clock cannot authorize stealing another widget's focus.
ui.run(GuardedFocus::new(
"editor".into(),
clock.clone(),
clock.load(Ordering::Acquire),
));
assert!(ui.focused("find"));
assert!(!ui.focused("editor"));
}
#[test]
fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() {
let clock = Arc::new(AtomicU64::new(0));
let mut ui = Mounted::new(clock.clone());
let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0);
ui.pass(&mut operation);
let Outcome::Chain(mut apply) = <GuardedFocus as widget::Operation<()>>::finish(&operation)
else {
panic!("mounted, unopposed target must be eligible");
};
clock.store(1, Ordering::Release);
ui.pass(apply.as_mut());
assert!(!ui.focused("editor"));
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1));
assert!(ui.focused("editor"));
ui.run(GuardedFocus::new("removed-editor".into(), clock, 1));
assert!(ui.focused("editor"));
}
#[test]
fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() {
let clock = AtomicU64::new(0);
let events = [
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}),
Event::Touch(touch::Event::FingerPressed {
id: touch::Finger(1),
position: Point::ORIGIN,
}),
Event::Window(iced::window::Event::Unfocused),
];
for (index, event) in events.iter().enumerate() {
invalidate(&clock, event);
assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1);
}
invalidate(
&clock,
&Event::Mouse(mouse::Event::CursorMoved {
position: Point::ORIGIN,
}),
);
invalidate(&clock, &Event::Window(iced::window::Event::Focused));
assert!(is_current(&clock, 5));
clock.store(u64::MAX - 1, Ordering::Release);
invalidate(&clock, &events[0]);
assert!(!is_current(&clock, u64::MAX));
invalidate(&clock, &events[0]);
assert_eq!(clock.load(Ordering::Acquire), u64::MAX);
assert!(!is_current(&clock, 0));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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 { .. })
));
}
}
+217 -4
View File
@@ -1,7 +1,7 @@
//! Per-file drafts and navigation. Accepted model/source history lives in Document.
use iced::widget::text_editor::Cursor;
use oa_core::{
language,
Id, language,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
};
use std::collections::{BTreeMap, BTreeSet};
@@ -86,6 +86,7 @@ pub struct Buffer {
pub cursor: Cursor,
past: Vec<String>,
future: Vec<String>,
generation: u64,
}
impl Buffer {
@@ -99,14 +100,20 @@ impl Buffer {
},
past: Vec::new(),
future: Vec::new(),
generation: 0,
}
}
pub fn dirty(&self) -> bool {
self.text != self.baseline
}
pub fn generation(&self) -> u64 {
self.generation
}
}
pub struct Workspace {
session: Id,
revision: u64,
pub entry: String,
pub active: String,
pub files: BTreeMap<String, Buffer>,
@@ -117,6 +124,35 @@ pub struct Workspace {
}
impl Workspace {
/// Ephemeral editor identity, never a model identity or recovery payload.
pub fn session(&self) -> Id {
self.session
}
pub fn revision(&self) -> u64 {
self.revision
}
fn advance(&mut self) -> u64 {
if let Some(revision) = self.revision.checked_add(1) {
self.revision = revision;
} else {
self.reset_navigation_session();
}
self.revision
}
/// A changed filesystem root must not reuse queued navigation from Save As.
pub fn reset_navigation_session(&mut self) {
self.session = Id::new_v4();
self.revision = 0;
for buffer in self.files.values_mut() {
buffer.generation = 0;
}
}
pub fn set_reachable(&mut self, reachable: BTreeSet<String>) {
if self.reachable != reachable {
self.advance();
self.reachable = reachable;
}
}
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
RecoveryWorkspace {
entry: self.entry.clone(),
@@ -136,6 +172,8 @@ impl Workspace {
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
saved.validate()?;
Ok(Self {
session: Id::new_v4(),
revision: 0,
entry: saved.entry,
active: saved.active,
files: saved
@@ -154,6 +192,8 @@ impl Workspace {
}
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
Self {
session: Id::new_v4(),
revision: 0,
entry: bundle.entry.clone(),
active: bundle.entry.clone(),
files: bundle
@@ -191,11 +231,20 @@ impl Workspace {
}
}
pub fn edit(&mut self, text: String, cursor: Cursor) {
let changed = self.files.get(&self.active).is_none_or(|b| b.text != text);
let generation = if changed {
self.advance()
} else {
self.revision
};
let buffer = self
.files
.entry(self.active.clone())
.or_insert_with(|| Buffer::new(String::new()));
buffer.cursor = cursor;
if changed {
buffer.generation = generation;
}
if text != buffer.text {
buffer.past.push(std::mem::replace(&mut buffer.text, text));
if buffer.past.len() > 100 {
@@ -268,6 +317,8 @@ impl Workspace {
} else {
buffer.future.push(previous);
}
let generation = self.advance();
self.files.get_mut(&self.active).unwrap().generation = generation;
self.trim_history(DRAFT_HISTORY_BYTES);
true
}
@@ -281,6 +332,7 @@ impl Workspace {
}
}
pub fn accept(&mut self, bundle: &SourceBundle) {
let generation = self.advance();
if self.entry != bundle.entry
&& !bundle.files.contains_key(&self.entry)
&& self.files.get(&self.entry).is_some_and(|b| !b.dirty())
@@ -291,7 +343,13 @@ impl Workspace {
self.reachable = bundle.files.keys().cloned().collect();
for (path, text) in &bundle.files {
let cursor = self.files.get(path).map(|b| b.cursor);
let previous_generation = self
.files
.get(path)
.filter(|b| b.text == *text)
.map(|b| b.generation);
let mut buffer = Buffer::new(text.clone());
buffer.generation = previous_generation.unwrap_or(generation);
if let Some(cursor) = cursor {
buffer.cursor = cursor;
}
@@ -303,10 +361,22 @@ impl Workspace {
self.applied = true;
}
pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet<String>) {
let changed = self.reachable != reachable
|| bundle
.files
.keys()
.any(|path| !self.files.contains_key(path));
let generation = if changed {
self.advance()
} else {
self.revision
};
for (path, text) in &bundle.files {
self.files
.entry(path.clone())
.or_insert_with(|| Buffer::new(text.clone()));
self.files.entry(path.clone()).or_insert_with(|| {
let mut buffer = Buffer::new(text.clone());
buffer.generation = generation;
buffer
});
}
self.reachable = reachable;
}
@@ -314,6 +384,7 @@ impl Workspace {
if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) {
return false;
}
self.advance();
self.files.remove(path);
if self.active == path {
self.active = self.entry.clone();
@@ -376,6 +447,148 @@ pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
mod tests {
use super::*;
use oa_core::Project;
#[test]
fn fresh_session_cannot_reuse_a_retained_buffer_generation() {
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([
("main.arcadia".into(), "first".into()),
("other.arcadia".into(), "other".into()),
]),
};
let mut workspace = Workspace::new(&bundle, true);
let cursor = workspace.files["main.arcadia"].cursor;
workspace.edit("second".into(), cursor);
workspace.reset_navigation_session();
let before = workspace.files["main.arcadia"].generation();
workspace.edit("third".into(), cursor);
assert_ne!(workspace.files["main.arcadia"].generation(), before);
workspace.switch("other.arcadia", cursor);
workspace.revision = u64::MAX;
workspace.edit("changed other".into(), cursor);
let after_wrap = workspace.files["main.arcadia"].generation();
workspace.switch("main.arcadia", cursor);
workspace.edit("fourth".into(), cursor);
assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap);
assert!(workspace.undo(false));
assert_eq!(workspace.text(), "third");
}
#[test]
fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "entry\r\n".into()),
("part.arcadia".into(), "雨 draft\r\n".into()),
]),
};
let mut workspace = Workspace::new(&bundle, true);
let reopened = Workspace::new(&bundle, true);
let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap();
assert_ne!(workspace.session(), reopened.session());
assert_ne!(workspace.session(), recovered.session());
assert_eq!(workspace.bundle(), recovered.bundle());
let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap();
assert!(saved.get("session").is_none() && saved.get("revision").is_none());
let cursor = Cursor {
position: iced::widget::text_editor::Position { line: 0, column: 3 },
selection: None,
};
let revision = workspace.revision();
workspace.edit(workspace.text().into(), cursor);
assert_eq!(
workspace.revision(),
revision,
"selection is not a text change"
);
workspace.switch("part.arcadia", cursor);
assert_eq!(
workspace.revision(),
revision,
"switching retains the snapshot"
);
let untouched = workspace.files["project.arcadia"].generation();
workspace.edit("雨 unfinished !\r\n".into(), cursor);
let edited = workspace.files["part.arcadia"].generation();
assert!(workspace.revision() > revision);
assert_eq!(workspace.files["project.arcadia"].generation(), untouched);
assert!(workspace.undo(false));
assert_eq!(workspace.bundle(), bundle);
assert!(workspace.files["part.arcadia"].generation() > edited);
assert!(workspace.undo(true));
assert_eq!(workspace.text(), "雨 unfinished !\r\n");
let previous_session = workspace.session();
let previous_text = workspace.bundle();
workspace.reset_navigation_session();
assert_ne!(workspace.session(), previous_session);
assert_eq!(workspace.bundle(), previous_text);
assert!(
workspace.undo(false),
"session reset preserves text history"
);
}
#[test]
fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "entry".into()),
("part.arcadia".into(), "part".into()),
]),
};
let mut workspace = Workspace::new(&bundle, true);
let entry_generation = workspace.files[&bundle.entry].generation();
let initial_revision = workspace.revision();
workspace.set_reachable(BTreeSet::from([bundle.entry.clone()]));
assert!(workspace.revision() > initial_revision);
let membership_revision = workspace.revision();
workspace.set_reachable(workspace.reachable.clone());
assert_eq!(workspace.revision(), membership_revision);
assert!(workspace.discard_detached("part.arcadia"));
assert!(workspace.revision() > membership_revision);
let closed_revision = workspace.revision();
workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()]));
assert!(workspace.revision() > closed_revision);
assert_eq!(
workspace.files[&bundle.entry].generation(),
entry_generation
);
assert!(workspace.files["part.arcadia"].generation() > closed_revision);
let discovered_revision = workspace.revision();
workspace.discovered(&bundle, workspace.reachable.clone());
assert_eq!(workspace.revision(), discovered_revision);
workspace.accept(&bundle);
assert!(
workspace.revision() > discovered_revision,
"validation evidence changed"
);
assert_eq!(
workspace.files[&bundle.entry].generation(),
entry_generation
);
assert_eq!(workspace.bundle(), bundle);
}
#[test]
fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), "original".into())]),
};
let mut workspace = Workspace::new(&bundle, true);
workspace.revision = u64::MAX;
let before = workspace.session();
let cursor = workspace.files[&bundle.entry].cursor;
workspace.edit("changed".into(), cursor);
assert_ne!(workspace.session(), before);
assert_eq!(workspace.revision(), 0);
assert!(workspace.undo(false));
assert_eq!(workspace.bundle(), bundle);
}
#[test]
fn draft_history_budget_covers_inactive_files_without_discarding_drafts() {
let bundle = SourceBundle {
+30 -3
View File
@@ -377,7 +377,8 @@ fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
send(&mut app, Message::SourceForSelection);
assert_eq!(app.page, Page::Text);
assert!(app.source_jump.is_some());
send(&mut app, Message::SourceViewport);
let ticket = app.source_reveal_ticket();
send(&mut app, Message::SourceViewport(ticket));
assert!(app.source_jump.is_none());
send(&mut app, Message::SourceForSelection);
assert!(app.source_jump.is_some());
@@ -409,7 +410,8 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
cursor.selection,
Some(text_editor::Position { line: 0, column: 9 })
);
send(&mut app, Message::SourceViewport);
let ticket = app.source_reveal_ticket();
send(&mut app, Message::SourceViewport(ticket));
assert_eq!(app.source_editor.cursor(), cursor);
send(
&mut app,
@@ -1269,7 +1271,26 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
.get_mut(&ancestor)
.unwrap()
.collapsed = true;
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
// The usage query reports rendered objects; this port's owner is folded.
// The legacy authoring reveal below explicitly expands its saved ancestor.
assert!(
!oa_core::projection::project_view(&p, diagram)
.ports
.iter()
.any(|shown| shown.element == port)
);
assert!(crate::diagram_usage::saved_usages_for(&p, port, Some(diagram)).is_empty());
// Other saved views of this function may still paint the port.
let elsewhere = crate::diagram_usage::saved_usages(&p, port);
assert!(
!elsewhere.is_empty(),
"the demo still paints this port in another saved view"
);
assert!(
elsewhere
.iter()
.all(|usage| usage.surface != crate::diagram_usage::Surface::Saved(diagram))
);
let mut app = App::with_project(p);
send(&mut app, Message::RevealInDiagram(diagram, port));
assert_eq!(app.selected, port);
@@ -1281,6 +1302,12 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
.iter()
.any(|p| p.element == port)
);
assert!(
crate::diagram_usage::saved_usages_for(app.project(), port, Some(diagram))
.iter()
.any(|usage| matches!(usage.target,
crate::diagram_usage::Target::Port { port: found, .. } if found == port))
);
}
#[test]
+536 -12
View File
@@ -1,3 +1,4 @@
mod source_symbols;
pub(crate) mod traceability;
use crate::{
app::{App, Choice, FileOperation, Message, Page, Pending},
@@ -352,6 +353,13 @@ pub fn view(app: &App) -> View<'_, Message> {
if app.pending.is_some() {
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
}
if app.source_rename.open() {
return modal_width(
root,
crate::app::source_rename::dialog(app),
(app.window_size.width - 32.).clamp(120., 760.),
);
}
root
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
@@ -449,6 +457,9 @@ fn diagram(app: &App) -> View<'_, Message> {
view_index: Some(app.document.view_index()),
diagram: id,
selected: app.selected,
navigation_focus: app
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
.map(|usage| usage.bounds),
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -469,10 +480,17 @@ fn diagram(app: &App) -> View<'_, Message> {
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
scene = scene.push(editor);
}
let reveal = app.architecture_reveal_stamp(id);
row![
widget::sensor(scene)
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized),
.on_show(move |size| reveal
.map_or(Message::CanvasResized(size), |(context, epoch)| {
Message::DiagramRevealViewport(context, epoch, size)
}))
.on_resize(move |size| reveal
.map_or(Message::CanvasResized(size), |(context, epoch)| {
Message::DiagramRevealViewport(context, epoch, size)
})),
crate::app::architecture_authoring::palette(app)
]
.height(Fill)
@@ -588,7 +606,7 @@ fn inspector(app: &App) -> View<'_, Message> {
return container(
column![
tabs,
scrollable(crate::navigation::view(p, app.selected)).height(Fill)
scrollable(crate::navigation::view(app, app.selected)).height(Fill)
]
.spacing(14),
)
@@ -1006,6 +1024,13 @@ fn source_files(app: &App) -> View<'_, Message> {
fn source(app: &App) -> View<'_, Message> {
use crate::source_navigation::Event as SourceEvent;
let (undo_label, undo_message) = match app.source_rename_undo_message() {
Some(message) => ("Undo rename", Some(message)),
None => (
"Undo model",
(app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo),
),
};
let navigation: View<'_, Message> = if app.source_navigation.open {
column![
row![
@@ -1022,7 +1047,7 @@ fn source(app: &App) -> View<'_, Message> {
),
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
label(app.source_navigation.label()),
action("Close", Message::SourceNavigation(SourceEvent::Close)),
action("Close", app.source_find_close_message()),
]
.spacing(6)
.align_y(iced::Center),
@@ -1116,8 +1141,13 @@ fn source(app: &App) -> View<'_, Message> {
}
}
let editor = widget::text_editor(&app.source_editor)
.id("source-editor")
.id(app.source_editor_id())
.on_action(Message::SourceEdit)
.key_binding({
let context = app.source_symbol_context();
let rename = app.source_rename_begin();
move |press| source_binding(press, &context, Some(&rename))
})
.font(Font::MONOSPACE)
.size(14)
.padding(16)
@@ -1126,8 +1156,7 @@ fn source(app: &App) -> View<'_, Message> {
app.source_snapshot.clone(),
crate::highlight::format,
);
column![
container(
let header = container(
column![
row![
container(heading(
@@ -1145,8 +1174,8 @@ fn source(app: &App) -> View<'_, Message> {
row![
action("Undo text", Message::SourceUndo),
action("Redo text", Message::SourceRedo),
button(text("Undo model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
button(text(undo_label).size(12)).padding([8,10]).style(style::nav)
.on_press_maybe(undo_message),
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
@@ -1170,17 +1199,512 @@ fn source(app: &App) -> View<'_, Message> {
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
}),
navigation,
row![
action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))),
action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))),
action("Rename alias · F2", app.source_rename_message()),
action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)),
button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav)
.on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))),
].spacing(7),
scrollable(diagnostics)
.height((visible_diagnostics as f32 * 32.).min(132.))
]
.spacing(12)
)
.padding(18),
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
.padding(18);
let editor = widget::sensor(editor)
.key((
app.source_workspace.active.clone(),
app.source_reveal_ticket(),
))
.on_show({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
})
.on_resize({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
});
let workspace: View<'_, Message> = if app.source_symbols.open {
row![
container(editor).width(Fill).height(Fill),
container(scrollable(source_symbols::panel(app)))
.width(300)
.height(Fill),
]
.height(Fill)
.into()
} else {
container(editor).width(Fill).height(Fill).into()
};
source_layout(header.into(), workspace, app.window_size.height)
}
fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
header: View<'a, Message, iced::Theme, Renderer>,
workspace: View<'a, Message, iced::Theme, Renderer>,
window_height: f32,
) -> View<'a, Message, iced::Theme, Renderer> {
// Lint clears and repopulates diagnostics asynchronously. Reserve the same
// outer slot so a pointer aimed at the editor cannot land in the header.
column![
container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)),
workspace,
]
.height(Fill)
.into()
}
pub(crate) fn source_binding(
press: widget::text_editor::KeyPress,
context: &crate::source_navigation::SymbolContext,
rename: Option<&crate::app::source_rename::Begin>,
) -> Option<widget::text_editor::Binding<Message>> {
use crate::source_navigation::{SymbolEvent, query::Action};
use iced::keyboard::{Key, key::Named};
use widget::text_editor::{Binding, Status};
if matches!(press.status, Status::Focused { .. })
&& press.modifiers.is_empty()
&& press.key == Key::Named(Named::F2)
&& let Some(begin) = rename
{
return Some(Binding::Custom(Message::SourceRename(
crate::app::source_rename::Event::Begin(begin.clone()),
)));
}
// Windows owns Alt+Space for its window menu. Iced's default binding would
// otherwise insert a supplied space payload, even after that menu closes.
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
#[cfg(target_os = "windows")]
if matches!(press.status, Status::Focused { .. })
&& press.modifiers == iced::keyboard::Modifiers::ALT
&& matches!(
press.key.as_ref(),
Key::Named(Named::Space) | Key::Character(" ")
)
{
return None;
}
if matches!(press.status, Status::Focused { .. })
&& !press.modifiers.control()
&& !press.modifiers.logo()
{
let event = match &press.key {
Key::Named(Named::F12) if !press.modifiers.alt() => {
Some(SymbolEvent::Query(if press.modifiers.shift() {
Action::References
} else {
Action::Definition
}))
}
Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => {
Some(SymbolEvent::Back)
}
_ => None,
};
if let Some(event) = event {
return Some(Binding::Custom(Message::SourceSymbols(
context.clone(),
event,
)));
}
}
Binding::from_key_press(press)
}
#[cfg(all(test, debug_assertions))]
pub(crate) mod source_tests {
use super::*;
use iced::advanced::{
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
};
use iced::widget::column;
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
pub(crate) 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 = graphics::text::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 {
header: Option<(Rectangle, Rectangle, Vector)>,
editor: Option<Rectangle>,
}
impl core_widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) {
if id == Some(&core_widget::Id::new("test-source-editor")) {
self.editor = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&core_widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn core_widget::operation::Scrollable,
) {
if id == Some(&core_widget::Id::new("source-header")) {
self.header = Some((bounds, content, offset));
}
}
}
fn mounted_source<'a>(
content: &'a widget::text_editor::Content<FontLayout>,
diagnostics: usize,
find: bool,
window_height: f32,
) -> View<'a, Message, iced::Theme, FontLayout> {
let mut notices = column![].spacing(5);
for index in 0..diagnostics {
notices = notices.push(button(text(format!(
"救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1,
)).size(12)).padding([4, 6]).width(Fill));
}
let find: View<'_, Message, iced::Theme, FontLayout> = if find {
column![
text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Query(s)
)),
text_input("Line", "1").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Line(s)
)),
]
.spacing(8)
.into()
} else {
Space::new().into()
};
let header = container(
column![
text("Textual model").size(24),
text("Edit the current file. Apply text validates the complete included model.")
.size(11),
row![
button(text("Apply text")),
button(text("Undo text")),
button(text("Model Undo"))
]
.spacing(8),
text("救援/assess.arcadia · All files applied to the accepted model").size(11),
text("Undo text affects this draft. Model Undo restores accepted files together.")
.size(11),
find,
row![
button(text("Definition · F12")),
button(text("References · Shift F12")),
button(text("Back"))
]
.spacing(7),
scrollable(notices).height((diagnostics as f32 * 32.).min(132.)),
]
.spacing(12),
)
.padding(18);
let editor = container(
widget::text_editor(content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill),
)
.id("test-source-editor")
.height(Fill)
.width(Fill);
source_layout(header.into(), editor.into(), window_height)
}
#[test]
fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() {
for (host, window_height) in [
(Size::new(720., 540.), 700.),
(Size::new(1180., 890.), 1080.),
] {
let content = widget::text_editor::Content::<FontLayout>::with_text(
"// is excluded\r\n// 雨\r\n",
);
let mut tree = core_widget::Tree::empty();
let mut baseline = None;
for (diagnostics, find) in [
(0, false),
(6, false),
(0, false),
(6, true),
(1, true),
(0, false),
] {
let mut ui = mounted_source(&content, diagnostics, find, window_height);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&FontLayout,
&mut geometry,
);
let editor = geometry.editor.unwrap();
let (header, _, _) = geometry.header.unwrap();
let slot = node.children()[0].bounds();
assert_eq!(slot.height, (window_height * 0.42).max(180.));
assert!(
header.height <= slot.height,
"short content may shrink inside the fixed slot"
);
assert!(
editor.height >= 240.,
"remaining editor must stay usable: {editor:?}"
);
assert_eq!(editor.y, slot.y + slot.height);
if let Some(expected) = baseline {
assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}");
} else {
baseline = Some(editor);
}
}
}
}
#[test]
fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() {
let content = widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n");
let host = Size::new(720., 540.);
let mut tree = core_widget::Tree::empty();
let mut saved = None;
for diagnostics in [6, 7] {
let mut ui = mounted_source(&content, diagnostics, true, 700.);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let (bounds, contents, _) = geometry.header.unwrap();
assert!(
contents.height > bounds.height,
"fixture must actually overflow"
);
if saved.is_none() {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
ui.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. },
}),
Layout::new(&node),
iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)),
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(host),
);
assert!(shell.is_event_captured());
}
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let offset = geometry.header.unwrap().2;
assert!(offset.y > 0., "the actual mounted Scrollable must move");
if let Some(before) = saved {
assert_eq!(offset, before);
} else {
saved = Some(offset);
}
}
}
#[cfg(target_os = "windows")]
#[test]
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
use core_widget::operation::Focusable;
use iced::keyboard::{
self, Key, Modifiers,
key::{Code, Named, Physical},
};
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
let baseline = "// is excluded\r\n// 雨\r\n";
let context = crate::source_navigation::SymbolContext {
session: Id::new_v4(),
path: "救援/assess.arcadia".into(),
};
for (key, physical_key, modifiers, payload, inserted) in [
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::ALT,
" ",
false,
),
(
Key::Character(" ".into()),
Physical::Code(Code::Space),
Modifiers::ALT,
" ",
false,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::empty(),
" ",
true,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::CTRL | Modifiers::ALT,
" ",
true,
),
(
Key::Character("e".into()),
Physical::Code(Code::KeyE),
Modifiers::CTRL | Modifiers::ALT,
"€",
true,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::ALT | Modifiers::SHIFT,
" ",
true,
),
] {
let mut content = Content::<FontLayout>::with_text(baseline);
content.move_to(widget::text_editor::Cursor {
position: widget::text_editor::Position { line: 0, column: 5 },
selection: None,
});
let press = KeyPress {
key: key.clone(),
modified_key: key.clone(),
physical_key,
modifiers,
text: Some(payload.into()),
status: Status::Focused { is_hovered: false },
};
assert!(
matches!(
Binding::<Message>::from_key_press(press),
Some(Binding::Insert(_))
),
"the supplied event must expose the default insertion path"
);
let mut messages = Vec::new();
{
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
widget::text_editor(&content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill)
.key_binding(|press| source_binding(press, &context, None))
.into();
let mut tree = core_widget::Tree::new(editor.as_widget());
let size = Size::new(600., 240.);
let node = editor.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, size),
);
tree.state
.downcast_mut::<State<shaped::highlighter::PlainText>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key,
location: keyboard::Location::Standard,
modifiers,
text: Some(payload.into()),
repeat: false,
});
let mut shell = Shell::new(&mut messages);
editor.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(size),
);
}
assert_eq!(
messages.len(),
usize::from(inserted),
"modifiers={modifiers:?}"
);
for message in messages {
let Message::SourceEdit(action) = message else {
panic!("unexpected navigation action");
};
content.perform(action);
}
let expected = if inserted {
format!("// is{payload} excluded\r\n// 雨\r\n")
} else {
baseline.into()
};
assert_eq!(content.text(), expected);
}
}
}
fn checks(app: &App) -> View<'_, Message> {
let issues = &app.model_issues;
let mut out = column![heading(
@@ -0,0 +1,260 @@
use crate::{
app::{App, Message},
source_navigation::{
SymbolEvent,
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
},
style,
};
use iced::{
Element, Fill,
widget::{Space, button, column, container, row, scrollable, text, tooltip},
};
pub(super) fn panel(app: &App) -> Element<'_, Message> {
let state = &app.source_symbols;
if !state.open
|| (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none())
{
return column![].into();
}
let title = state.results.as_ref().map_or_else(
|| "Source symbols".to_owned(),
|results| {
let action = match results.action {
query::Action::Definition => "Definitions",
query::Action::References => "References",
};
results.alias.as_ref().map_or_else(
|| action.to_owned(),
|alias| format!("{action} · @{}", preview(alias, 40)),
)
},
);
let mut content = column![
row![
text(title).size(13),
Space::new().width(Fill),
button(text("Close").size(11))
.padding([3, 8])
.style(style::nav)
.on_press(app.source_symbol_message(SymbolEvent::Close)),
]
.align_y(iced::Center)
]
.spacing(5);
if let Some(notice) = &state.notice {
content = content.push(super::label(preview(notice, 240)));
}
if state.in_flight.is_some() {
content = content.push(super::label("Finding source symbols…"));
} else if let Some(results) = &state.results {
let stale = results.stamp.snapshot != app.source_key();
if stale {
content = content.push(super::label(
"Stale results — source changed. Run Definition or References again.",
));
}
let scope = &results.scope;
content = content.push(super::label(format!(
"{} · {} · {} included / {} detached / {} unverified files · {} scope issues",
if scope.partial() {
"Partial scope"
} else {
"Complete include scope"
},
if !scope.applied {
"Unapplied drafts"
} else if scope.validated {
"Validated sources"
} else {
"Draft sources"
},
scope.included_files,
scope.detached_files,
scope.unverified_files,
scope.issues.len(),
)));
let explanation = match results.disposition {
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
Disposition::Choices => {
"Choose a declaration; draft and detached candidates need an explicit choice."
}
Disposition::Direct => "Definition in the current sources.",
Disposition::LexicalReferences => {
"Lexical uses of this alias; these do not establish a semantic target."
}
};
content = content.push(super::label(explanation));
if results.total_definitions > 1 {
content = content.push(super::label(format!(
"Alias collision: {} declarations. Uses are not attributed to a single declaration.",
results.total_definitions,
)));
}
let enabled = !stale && !app.busy;
let mut rows = column![super::label(counts(
"Declarations",
results.definitions.len(),
results.total_definitions,
results.omitted_definitions,
))]
.spacing(4);
for (index, definition) in results.definitions.iter().enumerate() {
rows = rows.push(location_row(
declaration_title(&definition.declaration),
&definition.path,
definition.position,
provenance(
definition.membership,
definition.evidence,
definition.declaration.identity,
false,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
})
}),
));
}
rows = rows.push(super::label(counts(
"Lexical uses",
results.references.len(),
results.total_references,
results.omitted_references,
)));
for (index, reference) in results.references.iter().enumerate() {
let title = reference.context.as_ref().map_or_else(
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|context| {
format!(
"@{} · inside {}",
preview(&reference.alias, 48),
declaration_title(context)
)
},
);
rows = rows.push(location_row(
title,
&reference.path,
reference.position,
provenance(
reference.membership,
reference.evidence,
reference
.context
.as_ref()
.map_or(Identity::Missing, |context| context.identity),
true,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: true,
index,
})
}),
));
}
let row_count = results.definitions.len() + results.references.len();
content =
content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0)));
}
container(content)
.padding(8)
.width(Fill)
.style(style::subtle)
.into()
}
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
format!("{label}: {total} total · {shown} shown · {omitted} omitted")
}
fn declaration_title(declaration: &Declaration) -> String {
let name = declaration
.name
.as_ref()
.map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48)));
format!(
"{}{} · @{}",
preview(&declaration.kind, 24),
name,
preview(&declaration.alias, 48)
)
}
fn provenance(
membership: Membership,
evidence: Evidence,
identity: Identity,
context: bool,
) -> String {
let membership = match membership {
Membership::Included => "Included",
Membership::Detached => "Detached",
Membership::Unverified => "Unverified",
};
let evidence = match evidence {
Evidence::Validated => "Validated",
Evidence::Draft => "Draft",
};
let collision = match identity {
Identity::Collision { count } => format!(
" · {}ID collision ({count})",
if context { "Context " } else { "" },
),
Identity::Missing | Identity::Unique(_) => String::new(),
};
format!("{membership} · {evidence}{collision}")
}
fn location_row(
title: String,
path: &str,
position: Position,
status: String,
event: Option<Message>,
) -> Element<'static, Message> {
let line = position.line.saturating_add(1);
let column = position.byte_column.saturating_add(1);
let location = format!("{}:{line}:{column}", preview(path, 72));
tooltip(
button(
column![
text(title).size(12),
super::label(format!("{location} · {status}"))
]
.spacing(2),
)
.padding([4, 6])
.width(Fill)
.style(style::nav)
.on_press_maybe(event),
text(format!(
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
))
.size(11),
tooltip::Position::Top,
)
.into()
}
/// Keep headings compact without copying the remainder of a long admitted value.
fn preview(value: &str, limit: usize) -> String {
let mut chars = value.chars();
let mut result: String = chars
.by_ref()
.take(limit)
.map(|ch| if ch.is_whitespace() { ' ' } else { ch })
.collect();
if chars.next().is_some() {
result.push('…');
}
result
}
+106
View File
@@ -0,0 +1,106 @@
Diagram representation navigation
=================================
This contract derives from user issues `14: Text symbol usages
<http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/14>`_
and `17: tree-to-diagram navigation
<http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/17>`_.
Implementation and acceptance status belong to those issues. The source language
and identity rules remain in ``language.rst``.
Identity and membership
-----------------------
A result identifies the queried subject, a diagram destination, and a specific
graphical representation. A destination is a saved diagram UUID, a process or
functional-chain UUID, or a scenario UUID. Saved state diagrams retain their own
diagram UUID; membership in the same state machine does not make their contents
interchangeable.
Representation identities are node placement, port plus owner placement, edge,
state row plus owner placement, process occurrence plus qualified reference path,
process link, or scenario lifeline/occurrence. Opening an owner itself is a
separate whole-diagram target. Names and aliases describe these identities; they
are never replacement identities. Repeated occurrences remain independently
selectable even when they represent the same activity or exchange.
The current renderer determines membership:
* Saved node, port, and edge results come from the final projection, including
folds, projection-kind rules, and view filters. A stored route or two placed
endpoints does not establish a visible edge.
* Process results use the displayed contribution graph. A contextual preview
retains its full caller path and is included only while its actual tile is
present. An inactive process uses the same initial preview policy as entering
its canvas. Preview adjacency has no invented relation UUID.
* Scenario results use the actual scene, preserving lifeline and message
occurrence identities, nested fragment operands, and repeated messages.
A scenario-reference box represents its called scenario. It does not also
represent the called scenario's messages or lifelines.
* State action and internal-transition rows retain their relation identity.
Visible named references, such as a state action's work or a transition's
trigger, can target the row or edge whose label displays that reference.
Label-only membership must account for the renderer's shortening policy.
An arbitrary semantic reference is insufficient. A description mentioning a
name, an exchange item hidden by the selected label mode, or an unexpanded callee
does not automatically add a diagram result. The result count describes
currently rendered representations, not all semantic dependencies. Folded,
filtered, and unexpanded representations are explicitly excluded by this version;
an empty result must disclose this scope and must not claim that an object is
unused everywhere. Semantic dependencies remain available through Connections.
Two additional representation forms remain gaps in this branch: allocation
relations expressed through nesting, and persisted sequence links drawn inside
qualified caller previews. These must either receive precise grouping/qualified
edge targets or remain explicitly disclosed limitations; a passing direct-edge
test does not establish their coverage.
Interaction and lifecycle
-------------------------
``Show in diagrams`` opens one searchable destination list from the Text caret,
an object's context/Actions menu, or Connections. It identifies the subject by
name and alias, groups targets by destination, reports counts, and presents a
bounded page. A destination then exposes its individual representations. Search
matches destination names, aliases, categories, representation names, and roles.
Long descriptions remain available without replacing stable identities.
The Text entry point uses the exact current caret and the source-symbol service's
immutable snapshot. It requires a unique, validated, included declaration whose
authored identity and alias still match the accepted model. It never substitutes
the previously selected tree object. Unapplied source is retained with an explicit
instruction to apply it before diagram lookup. Comment, prose, ambiguous, missing,
detached, and stale symbols cannot trigger a diagram jump.
Every open request captures the document instance, accepted revision, and subject.
Every chooser action captures that chooser's unique token. Before opening a
representation, re-resolve the full subject-to-target relationship and renderer
geometry. If the model or presentation changed, refresh the list and require a
fresh choice. Matching names, reused aliases, or surviving endpoints cannot make
a stale target current. An asynchronous source response must also match its job,
source session/revision, raw interaction, active file, and caret.
Lookup and reveal do not execute model commands, Apply, Revert, or Save. They do
not expand persisted folds, change filters, rewrite source, or alter undo/redo.
Pending edits that would be displaced prevent the jump and remain intact.
Successful reveal updates only the navigation owner/page, precise selection,
tool lifetime, and camera. The camera centers the target at a readable scale;
fitting a huge whole diagram is not sufficient evidence of locating one object.
Escape closes the chooser and restores Text focus/caret when invoked there.
Verification obligations
------------------------
Independent fixtures must cover actual versus filtered edges, folded children,
ports, same-name distinct identities, repeated work/message/lifeline occurrences,
qualified reuse paths, opaque scenario references, state rows and label references.
For each activation, compare the complete accepted Project, SourceBundle, raw
buffers, revision, dirty flags, and undo/redo reachability before and after.
Exercise stale document/revision/token/subject relationships and retained drafts.
Native acceptance additionally checks keyboard and pointer selection, exact
visible target highlight, camera behavior after the destination's first layout,
empty and multi-result states, readable long Unicode names, minimum-window layout,
return to editing, and representative large-model latency. Unit tests and a
successful build do not by themselves establish these interaction outcomes.
+79
View File
@@ -2642,6 +2642,85 @@ trace links, occurrence identity, comments and file ownership. Each open file
retains its own unapplied draft across file navigation. Located lint diagnostics
refer to the raw draft bytes rather than an identity-stabilized intermediate.
Property editing MUST NOT silently discard a pending source draft.</p>
<p>In Textual model, place the caret on a declaration alias or an <span class="docutils literal">&#64;reference</span>
and choose <strong>Definition</strong> (<span class="docutils literal">F12</span>) or <strong>References</strong> (<span class="docutils literal">Shift+F12</span>).
<strong>Back</strong> (<span class="docutils literal">Alt+Left</span>) returns to the previous source location in this
workspace. These shortcuts act while the source editor has focus; the Text
toolbar also exposes the actions. If the previous draft changed, Back restores
its current cursor rather than reusing an obsolete selection.</p>
<p>Symbol navigation reads bounded snapshots of the currently loaded source
drafts. It does not apply drafts or load missing included files. The results
identify partial scope, draft evidence, detached or unverified files, alias
ambiguity and authored-ID collisions. Draft, detached and ambiguous definition
candidates require an explicit row choice. References are lexical uses of an
alias, not a list of resolved typed relationships; comments and strings are
excluded. Declaration and use counts include any rows omitted by the result
limits. If sources change, stale rows cannot navigate: run the query again.
Navigation MUST preserve the accepted model and every source draft, and select
locations in the original UTF-8 bytes. Displayed locations use one-based lines
and UTF-8 byte columns.</p>
<p><strong>Show in diagrams</strong> finds representations of a model record in saved diagrams,
process/chain canvases and scenarios. Use the Text toolbar with the caret on a
unique, verified alias in clean accepted sources, or the model tree's context
menu for the chosen record. Choose a destination, then its exact representation.
Lookup runs from an accepted-model snapshot; navigation preserves source drafts,
accepted geometry and history. Pending edits or modal operations must be
finished first. Changed or stale results cannot activate a different target.</p>
<p>Membership follows the actual projection, including visible ports, state action
and internal-transition rows, qualified reuse tiles, and SequenceLink arrows
painted inside caller previews. Repeated callers have distinct destinations
within the same canvas. Hidden preview endpoints and deeper nested links that
the renderer does not draw are excluded; projected adjacency without an actual
relation is not a SequenceLink usage. Preview-arrow focus lies on a visible
line segment outside opaque tiles.</p>
<p>An Allocation projection represents a direct Allocation relation by grouping
only when its visible source performer placement occurs on the visible target
work placement's actual visual-parent chain. Aggregate-only allocation summaries
do not create direct relation usages. The navigation marker can focus a work
header, port owner or state row independently of semantic selection: selecting
an Allocation or state-action relation retains that relation as the model action
target. Camera movement preserves a valid marker; changing the selected record,
document, accepted revision or represented context retires it. Navigation does
not alter allocation ownership or the meaning of Delete.</p>
<p><strong>Rename alias</strong> (<span class="docutils literal">F2</span> while the source editor has focus) changes the alias
under the caret or its exact selection. It requires a unique authored identity
in the complete accepted source bundle, with no pending source, property or
behavior drafts and no extra loaded detached files. Accepted but unsaved source
is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID.
The engineering name and UUID remain unchanged.</p>
<p>The <strong>Source alias</strong> field does not trim or normalize the entered alias. Pasting
line breaks, tabs or other control characters is explicitly rejected as a whole:
the field and its selection remain unchanged, and any previous preview approval
is invalidated. <strong>Preview</strong>
prepares a typed alias change and displays every changed file and source line;
comments, strings, engineering data, file membership and declaration ownership
remain unchanged. Enter in the field requests Preview, never confirmation.
The preview is for this operation against accepted source, so a later Save may
also contain earlier accepted edits. A changing rename requires enough model
history capacity to retain its immediate Undo. An invalid, identical, stale or
oversized candidate cannot enable confirmation.</p>
<p>The preview MUST provide access to all admitted files, hunks, lines and long-line
segments. Refuse a candidate whose complete preview exceeds the editor's bounds:
256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes
(both versions of changed/context lines and file paths). Index metadata is
separately bounded to seven retained line records per changed line. These
limits do not restrict the language itself. A changed-line count is not a count
of semantic references. Cancel, Preview and Rename alias remain reachable
outside the independently scrolling source preview.</p>
<p>Explicit <strong>Rename alias</strong> confirms the current preview once, as one accepted
model/source transaction. A source, document, recovery or replacement change
invalidates its approval even if later bytes match the old preview. Closing the
form cancels approval; a late worker result MUST NOT reopen it. Rejected,
canceled and unchanged operations preserve accepted bytes, drafts, save state,
revisions, selection and both histories. Successful rename selects the new alias
at the original occurrence rather than the first matching spelling in the file.
Editing the replacement permits a new Preview in the same form. After a source
context change, close the form and invoke Rename again on the current alias.</p>
<p><strong>Undo rename</strong> is guarded against intervening edits and uses model history;
the persistent <strong>Undo model</strong> and <strong>Redo model</strong> controls restore the complete
accepted snapshots. Text <span class="docutils literal">Ctrl+Z</span> remains <strong>Undo text</strong>. Save is separate and
uses the ordinary guarded source-save contract. An unconfirmed preview and Undo
history are not persisted on reopening.</p>
<p>Changing a graphical UUID must atomically update map keys and every reference.
Alias changes preserve UUID identity. Deletion must account for owned elements,
incident relationships, ordered occurrences, and diagram placements without
+89
View File
@@ -2064,6 +2064,95 @@ retains its own unapplied draft across file navigation. Located lint diagnostics
refer to the raw draft bytes rather than an identity-stabilized intermediate.
Property editing MUST NOT silently discard a pending source draft.
In Textual model, place the caret on a declaration alias or an ``@reference``
and choose **Definition** (``F12``) or **References** (``Shift+F12``).
**Back** (``Alt+Left``) returns to the previous source location in this
workspace. These shortcuts act while the source editor has focus; the Text
toolbar also exposes the actions. If the previous draft changed, Back restores
its current cursor rather than reusing an obsolete selection.
Symbol navigation reads bounded snapshots of the currently loaded source
drafts. It does not apply drafts or load missing included files. The results
identify partial scope, draft evidence, detached or unverified files, alias
ambiguity and authored-ID collisions. Draft, detached and ambiguous definition
candidates require an explicit row choice. References are lexical uses of an
alias, not a list of resolved typed relationships; comments and strings are
excluded. Declaration and use counts include any rows omitted by the result
limits. If sources change, stale rows cannot navigate: run the query again.
Navigation MUST preserve the accepted model and every source draft, and select
locations in the original UTF-8 bytes. Displayed locations use one-based lines
and UTF-8 byte columns.
**Show in diagrams** finds representations of a model record in saved diagrams,
process/chain canvases and scenarios. Use the Text toolbar with the caret on a
unique, verified alias in clean accepted sources, or the model tree's context
menu for the chosen record. Choose a destination, then its exact representation.
Lookup runs from an accepted-model snapshot; navigation preserves source drafts,
accepted geometry and history. Pending edits or modal operations must be
finished first. Changed or stale results cannot activate a different target.
Membership follows the actual projection, including visible ports, state action
and internal-transition rows, qualified reuse tiles, and SequenceLink arrows
painted inside caller previews. Repeated callers have distinct destinations
within the same canvas. Hidden preview endpoints and deeper nested links that
the renderer does not draw are excluded; projected adjacency without an actual
relation is not a SequenceLink usage. Preview-arrow focus lies on a visible
line segment outside opaque tiles.
An Allocation projection represents a direct Allocation relation by grouping
only when its visible source performer placement occurs on the visible target
work placement's actual visual-parent chain. Aggregate-only allocation summaries
do not create direct relation usages. The navigation marker can focus a work
header, port owner or state row independently of semantic selection: selecting
an Allocation or state-action relation retains that relation as the model action
target. Camera movement preserves a valid marker; changing the selected record,
document, accepted revision or represented context retires it. Navigation does
not alter allocation ownership or the meaning of Delete.
**Rename alias** (``F2`` while the source editor has focus) changes the alias
under the caret or its exact selection. It requires a unique authored identity
in the complete accepted source bundle, with no pending source, property or
behavior drafts and no extra loaded detached files. Accepted but unsaved source
is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID.
The engineering name and UUID remain unchanged.
The **Source alias** field does not trim or normalize the entered alias. Pasting
line breaks, tabs or other control characters is explicitly rejected as a whole:
the field and its selection remain unchanged, and any previous preview approval
is invalidated. **Preview**
prepares a typed alias change and displays every changed file and source line;
comments, strings, engineering data, file membership and declaration ownership
remain unchanged. Enter in the field requests Preview, never confirmation.
The preview is for this operation against accepted source, so a later Save may
also contain earlier accepted edits. A changing rename requires enough model
history capacity to retain its immediate Undo. An invalid, identical, stale or
oversized candidate cannot enable confirmation.
The preview MUST provide access to all admitted files, hunks, lines and long-line
segments. Refuse a candidate whose complete preview exceeds the editor's bounds:
256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes
(both versions of changed/context lines and file paths). Index metadata is
separately bounded to seven retained line records per changed line. These
limits do not restrict the language itself. A changed-line count is not a count
of semantic references. Cancel, Preview and Rename alias remain reachable
outside the independently scrolling source preview.
Explicit **Rename alias** confirms the current preview once, as one accepted
model/source transaction. A source, document, recovery or replacement change
invalidates its approval even if later bytes match the old preview. Closing the
form cancels approval; a late worker result MUST NOT reopen it. Rejected,
canceled and unchanged operations preserve accepted bytes, drafts, save state,
revisions, selection and both histories. Successful rename selects the new alias
at the original occurrence rather than the first matching spelling in the file.
Editing the replacement permits a new Preview in the same form. After a source
context change, close the form and invoke Rename again on the current alias.
**Undo rename** is guarded against intervening edits and uses model history;
the persistent **Undo model** and **Redo model** controls restore the complete
accepted snapshots. Text ``Ctrl+Z`` remains **Undo text**. Save is separate and
uses the ordinary guarded source-save contract. An unconfirmed preview and Undo
history are not persisted on reopening.
Changing a graphical UUID must atomically update map keys and every reference.
Alias changes preserve UUID identity. Deletion must account for owned elements,
incident relationships, ordered occurrences, and diagram placements without
+39
View File
@@ -346,3 +346,42 @@ regressions; it does not broaden admission or add a new rule. Compilation and
native acceptance of the renderer are still pending at this decision. Revisit the
visual encoding if actual minimum-size engineering workflows make marker identity,
uncertain continuation or the distinction between guide and existence unclear.
## Diagram representation navigation — 2026-09-12
Codex-ytr83r /root. User issues [14](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/14)
and [17](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/17)
share one typed query and destination chooser; the [contract](../docs/diagram-navigation.rst)
defines identity, visible membership, source freshness and nonmutation. A result
retains its representation identity separately from its queried semantic subject.
Use the actual saved projection, process Graph, scenario Scene and state row
geometry. Repeated occurrences and qualified reuse paths remain distinct.
Reject endpoint-only membership and generic semantic-reference enumeration:
they admit suppressed edges and opaque callee contents while missing behavioral
occurrences and state rows. Reject a destination submenu containing every use or
always-visible per-line lenses: both add noise and scale poorly for the user's
editing workflow. An explicitly invoked, searchable chooser groups destinations
and exposes their occurrences on demand. Independent design work identified
minimum-window, repeated-name, source-caret, stale-result and full-history oracles
before implementation; passing those evaluators remains necessary for acceptance.
Ordinary navigation must not reuse the legacy reveal action's implicit Apply and
persisted fold expansion. Resolve the exact target before changing presentation;
center that target at readable scale. This first implementation lists currently
rendered representations and explicitly discloses folded/filtered/unexpanded
exclusions. It does not claim complete authored-membership discovery. Revisit
transient hidden-object reveal after measured user workflows; adding a result
must never imply an invisible object was actually shown or silently edit a view.
The Text entry point builds on delivered source navigation from33c6d83, including
eeeaa12 native corrections, while preserving the later canvas/chooser/repair
integration. It requires verified applied-source identity and never substitutes
model selection for the caret. Dirty-source semantic lookup remains a disclosed
limitation. Implementation is branch-specific at this decision; Windows build,
combined tests and native acceptance are not established by this record.
## 2026-09-12 — Cross-machine coordination evidence
User clarification: independent Gitea peer sessions run on different machines and cannot access this session’s `.local` directory. In-thread delegated agents share this workspace. Publish required source, reviews and runtime evidence through versioned Gitea commits or attachments before handing them to peers; verify access and link the exact version. Local paths are provenance only for remote peers. Local compiler, process and native-window observations do not establish another machine’s state or a shared hardware lease.
+9 -7
View File
@@ -1,12 +1,14 @@
# Integrated checkpoint — 2026-09-11
# Integrated checkpoint — 2026-09-12
Codex-ytr83r, isolated branch `codex/canvas-union-ytr83r-20260911`. Current source `cb91525` adds the three-file grapheme shortening correction to the combined `a57c334b302eb13352b4bb65df1c75d94d002348` source checkpoint; its remaining Windows gates and matching native checks remain pending. The a57/108b checkpoint evidence remains preserved; this source statement is not a release claim. Main checkpoint `32334c6` / production `865483b` and its existing package remain preserved. The full [user goal](../PROJECT_GOAL.md) remains incomplete. Live [canvas integration](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2), [native/release acceptance](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5) and the [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) retain ownership and plans. This file scopes code and evidence.
Codex-ytr83r, isolated branch `codex/canvas-union-ytr83r-20260911`. Current source `33e30c1` combines the a57 canvas, cb91525 grapheme and narrow saved-destination repair corrections. Its 530 frozen inputs match HEAD; independent repair review found no actionable finding. Windows desktop tests, strict Clippy, GUI build and bounded matching native/package acceptance passed. The [canvas-repair Windows prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.canvas-repair.20260912) is published; ZIP, checksum and validation attachments were downloaded and hash-verified. [Publication record](release-canvas-repair-publication-20260912.md) preserves the exact identities. This is not a full [user-goal](../PROJECT_GOAL.md) completion claim. Live [canvas integration](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2), [native/release acceptance](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5), [saved-destination repair](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6) and [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) retain ownership and plans. Earlier production `865483b` and its package remain preserved.
## Verified code and capabilities
Source `cb91525` changes the shared display helper to preserve extended grapheme clusters and records the direct locked dependency edge. Independent source review found no remaining actionable finding; four focused shortening tests passed at 20:59:36.0538079 UTC, with 752 filtered. Full desktop, strict Clippy, build and matching native checks remain pending in this record. The corrected 529-file input freeze supersedes only the premature pre-resolution freeze, against which no tests/build ran. [Correction, exact freezes and limits](release-grapheme-shortening-ytr83r-20260911.md), [verbatim independent review](release-grapheme-shortening-review-20260911.md).
Source `33e30c1` binds repair dirty state to the actual saved destination, preserving accepted-model history even when Apply is an identical-content no-op. It selectively incorporates peer `33c6d83` in three files. Windows focused repair **5**, full desktop **761**, strict desktop all-targets Clippy and GUI build passed; the five are included in 761. All **530 files / 9,727,025 bytes** remained unchanged between gates and matched HEAD after the build. The preserved GUI `50486253…` passed matching repair/history/recovery and Unicode selection/passive-save checks, packaged walkthrough/help smoke and normal exit0 with empty logs. All559 package files stayed unchanged; fresh extracted server25-request editing/persistence checks passed. [Current validation and artifact hashes](release-canvas-repair-validation-20260912.md) links the exact raw archives and independent reviews. [Repair union and limits](release-repair-union-ytr83r-20260912.md), [closed gates and exact inputs](experiments/repair-union-windows-33e30c1-20260912/README.md), [verbatim independent review](release-saved-destination-independent-review-20260912.md).
The isolated `a57c334` union selectively combines peer `a31b40d` canvas/chooser/form prerequisites with the preserved B4 lifetime renderer, contextual source/minimum-card corrections and responsibility-preserving child-work creation. Pan-aware header/Create clips and pointer inversion received independent source review. Windows lifecycle **27**, full desktop **752**, strict desktop all-target Clippy and Studio build passed; all 529 frozen inputs matched HEAD and remained unchanged. The new unoptimized GUI is `69DBB90F…`; matching a57 native checks are in progress locally; complete acceptance remains pending. [Canvas union checkpoint, source/gate archives and exact limits](release-canvas-union-ytr83r-20260911.md).
Source `cb91525` preserves extended grapheme clusters in the shared display helper. Its four focused tests, full desktop 756 and strict desktop Clippy passed; the four overlap the 756. Its GUI build was intentionally superseded by the repair union; matching combined33e native Unicode evidence is recorded above, without reattributing that result to cb91525. [Correction and limits](release-grapheme-shortening-ytr83r-20260911.md), [closed Windows evidence](experiments/grapheme-windows-cb91525-20260911/README.md).
The isolated `a57c334` union selectively combines peer `a31b40d` canvas/chooser/form prerequisites with B4 lifetime rendering and contextual source/child-work corrections. Windows lifecycle **27**, full desktop **752**, strict desktop Clippy and Studio build passed on 529 unchanged frozen inputs. GUI `69DBB90F…` passed bounded staged source/consumer creation, Cancel, Save, Undo/Redo and reopen checks: 13 oracles and 45 screenshots at the default wide window only. [Canvas checkpoint](release-canvas-union-ytr83r-20260911.md), [staged archive](experiments/staged-source-native-a57c334-20260911/README.md), [independent review](release-staged-native-a57-independent-review-20260911.md). A fresh a57 camera session subsequently passed bounded OA 100%/125%/Fit and minimum 1100×700 client Fit66 checks, OA/SA pan and moved Create/Delete hit selection, exact passive Save equality and normal exit 0. The 18 images support bounded clipping without toolbar bleed; labels are small at 66%. [Camera archive and limits](experiments/canvas-camera-a57c334-20260912/README.md), [verbatim independent review](release-camera-a57-independent-review-20260912.md). The [earlier picker failure](experiments/canvas-native-close-a57c334-20260911/README.md) remains historical evidence. These pre-grapheme a57 results do not establish current `33e30c1` native or complete usability acceptance.
Earlier published checkpoint `3ade397008cffe826d5f5a70e7c0233159e3bd42` contains production `29084050a7c020e0fb488be37c6e1ab235769cc0`; push and exact remote readback passed. It combines prior named behavior creation/history integration (`c81e285`, `e207b04`), corrected creation-oracle evidence (`b853a1e`), CLI-only integration (`023e3a8`) and B4 lifecycle rendering.
@@ -34,13 +36,13 @@ The closed 321897f Windows gate passed 704 desktop tests with zero failed/ignore
The later 865483b Windows desktop gate passed **707 tests**, including the three focused child-work regressions, with zero failed/ignored/filtered. All 261 recorded build inputs stayed unchanged. The matching debug GUI build and strict **desktop-only** all-targets Clippy passed; child-work and staged-source checker compilation/admission also passed. [Closed gates and exact inputs](experiments/windows-gates-865483b-20260911/README.md). These checks do not establish the remaining native rendering or packaged acceptance.
The extracted package's unchanged server `ABA87A5F…` passed the actual HTTP schema checks (204/19/9) and 25-request REST editing/persistence/restart probe with a restricted process PATH and unrelated working directory. Seven raw response pairs and exact source snapshots matched; all five test-owned server processes stopped. The first REST run's PowerShell-added PATH directory is preserved as historical evidence; the corrected restricted-PATH rerun is authoritative. [Packaged server evidence](experiments/packaged-server-865483b-20260911/README.md). This completes bounded server runtime acceptance, while the new Windows preview remains unpublished pending the separate GUI checks.
The extracted package's unchanged server `ABA87A5F…` passed the actual HTTP schema checks (204/19/9) and 25-request REST editing/persistence/restart probe with a restricted process PATH and unrelated working directory. Seven raw response pairs and exact source snapshots matched; all five test-owned server processes stopped. The first REST run's PowerShell-added PATH directory is preserved as historical evidence; the corrected restricted-PATH rerun is authoritative. [Packaged server evidence](experiments/packaged-server-865483b-20260911/README.md). This completed the earlier bounded server runtime acceptance. Fresh33e package/server/GUI results are linked above; the old receipts retain their own source attribution.
## Material limitations
Earlier native wide Latin/CJK checks found neutral lifeline header text overflowing its card while Create labels stayed contained. The [header correction](lifecycle-header-overflow-correction-ytr83r-20260911.md) is included in 865483b and retained in a57. Actual corrected wide-W/CJK observations in BF9 show bounded headers and Create labels, including selected zoom, scroll and minimum-window cases. Both complete wide and cluster sources remained unchanged after passive Save. Cluster checks exposed a separate scalar-shortening defect: an accent or joined emoji can be split at the label cutoff. Full Unicode presentation and matching a57 native acceptance remain open; see the [native typography record](experiments/lifecycle-header-correction-native-865483b-20260911/README.md).
Earlier native wide Latin/CJK checks found neutral lifeline header text overflowing its card while Create labels stayed contained. The [header correction](lifecycle-header-overflow-correction-ytr83r-20260911.md) is included in 865483b and retained in a57. Actual corrected wide-W/CJK observations in BF9 show bounded headers and Create labels, including selected zoom, scroll and minimum-window cases. Both complete wide and cluster sources remained unchanged after passive Save. Cluster checks exposed a separate scalar-shortening defect: an accent or joined emoji can be split at the label cutoff. The later cb91525 correction and current33e accent/ZWJ selection/passive-save checks resolve the observed string-splitting case. Long wide labels can still be hard-clipped and arbitrary font/DPI presentation is unverified; see the historical [native typography record](experiments/lifecycle-header-correction-native-865483b-20260911/README.md).
The earlier capture failures and pending Open dialog are historical: later confirmed OA and SA reopen/Save actions are preserved in the [continuation archive](experiments/native-child-continuation-865483b-20260911/README.md). Its first OA `reopened-1948` receipts remain data-only evidence; they are not promoted to successful-open observations. Contextual-source workflows, matching new-canvas pan/zoom/clipping/focus behavior and complete combined native/package acceptance remain unfinished. The BF9 Home page, scenario guide and bundled water-pressure example opened; one normal Alt+F4 then completed, with the process absent and empty stdout/stderr. The [closed Home/Guide/normal-close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves these bounded old865/BF9 observations and complete package readback. No application exit code was captured; no exit0 or new-source native pass is inferred.
The earlier capture failures and pending Open dialog are historical: later confirmed OA and SA reopen/Save actions are preserved in the [continuation archive](experiments/native-child-continuation-865483b-20260911/README.md). Its first OA `reopened-1948` receipts remain data-only evidence. Bounded a57 staged and camera workflows above are complete; full focus/keyboard, every clipping edge/zoom and accessibility remain unverified. Bounded combined `33e30c1` native/package acceptance is now linked above. Small 66% labels are a recorded limitation. The BF9 Home page, scenario guide and bundled water-pressure example opened; one normal Alt+F4 then completed, with the process absent and empty stdout/stderr. The [old Home/Guide/close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves those old865/BF9 observations and package readback. No exit code was captured for that older BF9 process; the separate a57 exit 0 does not change its evidence.
The selected chooser/canvas union now has a57 source and Windows gate evidence; broader persistent graphical manipulation and separate text-navigation contributions retain their own integration/acceptance boundaries. Calculations, quantities/units, maintained libraries, practical large-model textual workflows, complete OA↔SA engineering parity, documentation and fresh cross-disciplinary review remain full-goal requirements. See the [analysis proposal](release-analysis-semantics-design-20260910.md), [library proposal](release-libraries-semantics-design-20260910.md), [text-authoring design](release-textual-authoring-design-20260911.md) and [decisions](DECISIONS.md) for attributed design evidence, not completed implementation claims.
@@ -0,0 +1,11 @@
# Scenario layout policy experiment — bounded evidence
Question: can absolute desired coordinates and gap-before records express the same small feasible layouts, and what semantics must a movement planner preserve? This is an isolated JavaScript experiment, not production Rust or a substitute for actual Arcadia Scene, parser, REST, history, or native acceptance. The ten-unit separation and rectangle metrics are illustrative.
[Source](experiment.mjs) executed through Node REPL and wrote [results](results.json): 3,555 feasible three-row layouts and 53,325 single-move cases passed. Handwritten numeric cases expose different naive reorder/delete/resize behavior. Both encodings can express the enumerated feasible outputs. Changed-coordinate counts compare only the specific single-edit forward policy, not equally expressive retained intent across histories. Elapsed time includes assertions and this machine’s load; it is not a renderer performance result.
[Independent review](independent-review.md) found an omitted conflict-then-zero-motion history. [The negative probe](negative-zero-motion.mjs) reuses the same experiment with that additional assertion; root executed it and [reproduced the expected assertion failure](negative-zero-motion-result.json): a zero-screen-motion operation changes B.desired from200 to280. The original source/results remain preserved. No production failure is implied.
The next experiment must distinguish a pointer gesture (original frame and actual delta; zero motion preserves authored input) from an explicit SetDesiredY/Normalize operation, and test move-away/back and resize histories. Equal visible geometry does not imply equal latent layout intent. Before schema adoption, exercise one parsed real scenario through the actual Scene/attachment adapter, including self-message and nested/lifetime cases. The [design proposal and critique](../behavior-navigation-review-20260912/README.md) remain conditional.
To reproduce, copy experiment.mjs and negative-zero-motion.mjs into an empty directory and run `node experiment.mjs`, then `node negative-zero-motion.mjs`. The first writes results.json; the second must fail its zero-distance assertion. Existing output is never overwritten. Node built-ins only; no package installation or network is required. [Hashes](hashes.json) bind the exact executed source/result artifacts.
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
// Isolated policy comparison; these illustrative metrics are not Studio rules.
const separation = 10;
function absolute(rows) {
let end = -separation;
return rows.map(r => { const y = Math.max(r.y, end + separation); end=y+r.height; return {...r,y}; });
}
function gaps(rows) {
let end=0;
return rows.map((r,i) => { assert(r.gap >= (i?separation:0)); const y=end+r.gap; end=y+r.height; return {...r,y}; });
}
function encodeGaps(rows) {
return rows.map((r,i)=>({...r,gap:r.y-(i?rows[i-1].y+rows[i-1].height:0)}));
}
const positions = rows=>rows.map(r=>r.y);
function moveAbsolute(rows,index,requested) {
const current=absolute(rows);
const floor=index?current[index-1].y+current[index-1].height+separation:0;
const candidate=rows.map((r,i)=>({...r,y:i===index?Math.max(requested,floor):r.y}));
return {candidate,preview:absolute(candidate)};
}
function changeCount(before,after,key) {return before.reduce((n,r,i)=>n+(r[key]!==after[i][key]),0);}
const initial=[{id:'A',y:100,height:20},{id:'B',y:200,height:20}];
const initialG=encodeGaps(initial);
const cases=[];
function named(name,a,g,expectedA,expectedG) {
assert.deepEqual(positions(a),expectedA,name+' absolute');
assert.deepEqual(positions(g),expectedG,name+' gaps');
cases.push({name,absolute:positions(a),gapsWithoutInverse:positions(g)});
}
named('move A to50',absolute([{...initial[0],y:50},initial[1]]),gaps([{...initialG[0],gap:50},initialG[1]]),[50,200],[50,150]);
named('move A to250',absolute([{...initial[0],y:250},initial[1]]),gaps([{...initialG[0],gap:250},initialG[1]]),[250,280],[250,350]);
named('resize A to80',absolute([{...initial[0],height:80},initial[1]]),gaps([{...initialG[0],height:80},initialG[1]]),[100,200],[100,260]);
named('resize A to120',absolute([{...initial[0],height:120},initial[1]]),gaps([{...initialG[0],height:120},initialG[1]]),[100,230],[100,300]);
named('source reorder B A',absolute([initial[1],initial[0]]),gaps([initialG[1],initialG[0]]),[200,230],[80,200]);
named('delete A',absolute([initial[1]]),gaps([initialG[1]]),[200],[80]);
assert.deepEqual(positions(moveAbsolute(initial,1,110).preview),[100,130]);
let feasible=0,moves=0,absoluteChanges=0,gapChanges=0;
const started=performance.now();
for(const ha of [10,20,30])for(const hb of [10,20,30])for(const hc of [10,20,30])
for(let a=0;a<=40;a+=10)for(let b=a+ha+10;b<=90;b+=10)for(let c=b+hb+10;c<=140;c+=10) {
const rows=[{id:'A',height:ha,y:a},{id:'B',height:hb,y:b},{id:'C',height:hc,y:c}];
assert.deepEqual(positions(absolute(rows)),[a,b,c]);
assert.deepEqual(positions(gaps(encodeGaps(rows))),[a,b,c]);
feasible++;
for(let i=0;i<3;i++)for(const delta of [-50,-20,0,10,40]) {
const requested=rows[i].y+delta, {candidate,preview}=moveAbsolute(rows,i,requested);
const independent=[];
for(let j=0;j<3;j++) {
const floor=j?independent[j-1]+rows[j-1].height+10:0;
independent.push(Math.max(j===i?requested:rows[j].y,floor));
}
assert.deepEqual(positions(preview),independent);
assert.deepEqual(positions(absolute(preview)),independent);
assert.deepEqual(positions(absolute(JSON.parse(JSON.stringify(candidate)))),independent);
const encoded=encodeGaps(preview);
assert.deepEqual(positions(gaps(encoded)),independent);
assert.deepEqual(candidate.map(r=>r.id),['A','B','C']);
for(let j=0;j<i;j++) assert.deepEqual(candidate[j],rows[j]);
absoluteChanges+=changeCount(rows,candidate,'y');
gapChanges+=changeCount(encodeGaps(rows),encoded,'gap');
moves++;
}
}
// Independent event/frame attachment probes: no actual Scene/activation engine.
const selfCall=absolute([{id:'call',y:250,height:24},{id:'reply',y:320,height:0}]);
assert.equal(selfCall[0].y+24,274);
assert.equal(selfCall[1].y,320);
const children=[{y:160,height:20},{y:260,height:20}];
const requiredFrameBottom=Math.max(...children.map(r=>r.y+r.height))+20;
assert.equal(requiredFrameBottom,300);
assert.equal(Math.max(300,requiredFrameBottom),300);
assert.equal(Math.max(220,requiredFrameBottom),300);
assert.deepEqual(children,[{y:160,height:20},{y:260,height:20}]);
assert.equal(220-90,130); // delayed header top from moved create receive and header height
const result={
scope:'Illustrative linear policy and anchor experiment; no production parser, REST, Scene, GUI or full fragment/lifecycle acceptance.',
separation, cases, feasibleLayouts:feasible, moveCases:moves,
changedAuthoredCoordinateRecords:{absolute:absoluteChanges,gapsWithInverse:gapChanges},
elapsedMs:performance.now()-started,
assertions:'passed',
interpretation:'Both policies express all enumerated feasible layouts. Absolute coordinates preserve unaffected positions with fewer authored coordinate changes under these fixtures. Choose no production schema from this alone; full scope/anchor/inverse and source lifecycle validation remain required.'
};
await writeFile(new URL('./results.json',import.meta.url),JSON.stringify(result,null,2)+'\n',{flag:'wx'});
export default result;
@@ -0,0 +1,22 @@
[
{
"file": "experiment.mjs",
"bytes": 5123,
"sha256": "8a2b5adc35a25173ec2d37a46572cebac12d73bc8c9440f3cfdff784858c52c1"
},
{
"file": "results.json",
"bytes": 1633,
"sha256": "866e4c81c42a922033669aa6c591857029303627db28c5307b0126503a6791b8"
},
{
"file": "negative-zero-motion.mjs",
"bytes": 5383,
"sha256": "b76b1aa1c5d19043540381fc31224ecb86298796fb3adf2cdc8847c412b034ce"
},
{
"file": "negative-zero-motion-result.json",
"bytes": 707,
"sha256": "1dab9972fdef1586e48c89f5afe8dd6a3cfa5d58adf68088e74440dbf4eb70a9"
}
]
@@ -0,0 +1,55 @@
# Review of the isolated layout-policy experiment
2026-09-12. Independent reviewer `/root/diagram_usage_workflows`; read [experiment.mjs](experiment.mjs) and [results.json](results.json). Root reports executing the experiment. This review inspected code/results without rerunning them; no production edits, builds, UI, or Gitea operations. Local links are for this shared agent workspace; root will publish portable artifacts before any external peer handoff.
## Findings and justified conclusion
**Useful result:** both encodings reproduce all 3,555 enumerated feasible three-row layouts. Under the particular forward-only, minimum-gap movement policy, a gap inverse can reproduce the absolute planner's 53,325 single-move previews. This rebuts the idea that gap storage inherently prevents precise placement. Absolute coordinates are a reasonable candidate for the next experiment.
**Not established:** the best production policy, equivalent edit-history behavior, source/API parity, real scenario anchors, fragments/lifetimes, minimal record changes in general, responsiveness, or schema adoption. The result file correctly disclaims most of these. Its “fewer authored coordinate changes” claim should be scoped to single edits from initially feasible layouts, using this forward resolver and this gap encoding. It is not yet a fair comparison of equally expressive persistent intent across histories.
### 1. A concrete omitted history violates the proposed zero-movement contract
Starting authored A100/B200, height20 each:
1. `moveAbsolute(rows, A, 250)` stores A250/B200 and displays A250/B280.
2. Select B and release at unchanged displayed y280.
3. `moveAbsolute(candidate, B, 280)` stores B280, changing its previous desired200 despite no screen movement.
The experiment starts each move from a feasible layout, so its delta=0 cases cannot find this. A pointer gesture should carry its original frame, original authored candidate and actual pointer delta, returning that original candidate for zero delta before inverse mapping. That is an additional required contract, not something tested here. An explicit text/API `SetDesiredY` may legitimately change latent authored intent even when the current projected coordinate is equal; explicitly applying the displayed coordinate through Properties could likewise be a meaningful normalization operation. The toy function has no operation-intent input and is not a complete gesture planner. An untouched pointer gesture must remain a no-op.
Another important history is moving A back to100. Retaining absolute B.desired=200 restores B200, whereas the first gap inverse encoded the displaced output B280 and did not preserve B's original desired position. A gap planner can preserve the current visible B280 or use extra latent intent to restore200; choose the intended behavior explicitly. Neither result is intrinsically correct, but the current counters compare representations containing different information after the first conflict.
### 2. Oracle independence is partial
- The six named example outputs are hand-authored numerical expectations. They are useful independent checks of the proposed policy examples, though they do not exercise Studio.
- The loop called `independent` repeats the same left-to-right `max(requested-or-old, predecessor-bottom+10)` recurrence as `moveAbsolute` plus `absolute`. It can catch implementation inconsistencies but does not independently establish that the chosen movement policy matches user intent or is the least disruptive feasible solution.
- `absolute(preview)` checks idempotence on already resolved geometry. It does not prove that authored unresolved intent survives a sequence of commands or that the inverse preserves inactive fields.
- JSON stringify/parse of plain toy records demonstrates only that these small numeric values survive JavaScript JSON. There is no Arcadia grammar, source comment ownership, schema version, f32 precision, REST conditional transaction, or Document history exercised.
- Checking the three IDs remain A/B/C and earlier rows are unchanged is useful but does not exercise steps, sender/receiver, reply_to, branch membership, or lifecycle semantics.
- “Independent event/frame attachment probes” is too strong a label. `250+24=274`, `max(child bottoms)+20=300`, and `220-90=130` are arithmetic expectations. They do not invoke a self-message, frame resize or lifetime implementation. The unchanged-children assertion observes an array no code attempted to change. Treat these as fixture seeds for future tests.
### 3. Fairness and coverage of the comparison
Encoding the same final preview with `encodeGaps` is a fair test of gap expressiveness and a narrowly useful coordinate-update count. It is not an independent gap-policy optimizer: the desired final layout comes from the absolute policy. The named gap cases intentionally omit an inverse and should illustrate naive behavior only, not count as evidence against gap-based direct manipulation.
The recorded totals, 37,134 absolute-coordinate changes versus 61,371 gap changes, exclude latent-intent equivalence, bytes, comments, identities, transactions, secondary geometry and histories. An absolute edit leaves displaced followers' authored y unchanged; the gap inverse rewrites them to encode resolved positions. Count logical preserved intent, visible displacement and serialized changes separately. Also remove unused y fields from the conceptual gap representation when estimating storage; `encodeGaps` currently retains them through object spread even though `gaps` ignores/overwrites them.
The enumerator covers nonnegative integer tops on a ten-unit grid, three rows, fixed minimum separation, and five deltas. The third height varies but constrains no successor, so the count contains geometrically equivalent positioning cases. This does not invalidate the result; it limits the diversity implied by the headline number. Negative/fractional/extreme coordinates, variable per-kind clearances, mixed resize/move sequences, preexisting conflicts, simultaneous branches, nested frames and route geometry are absent. The elapsed time includes assertions and allocations; it is not a renderer performance measurement.
### 4. Important absent lifecycle/source cases
The isolated source-reorder and delete examples change array order/membership but test no cleanup or diagnostics. Missing are: repeated source reorder; deleting a displaced predecessor; insert between authored neighbors; rename/content growth; reparent between operands; reorder across call/reply or creation/deletion constraints; hidden records; reset one override; two saved views; first-edit identity capture; undo/redo; and comments keyed by typed addresses. Frame anchors need header/operand containment, while self calls need both send and receive geometry consumed by activation logic. These are specification gates, not reasons to expand the toy to thousands more similar rows.
## Bounded next experiment
First add a small history table to the isolated experiment: conflict then zero-distance drag, move away/back, resize away/back, delete predecessor, and source reorder/reverse. Assert authored records and displayed positions separately. Use a tiny exhaustive feasible-layout oracle with a declared lexicographic objective (selected target error, predecessor changes, other displacement, then record changes) to check a few cases without duplicating the forward recurrence. The objective itself remains a policy proposal to review. Run both candidate representations through their own repeated histories rather than deriving every gap state from an absolute preview.
Then stop scaling the toy and make **one real-model renderer experiment**, isolated from the production schema:
1. Parse a real minimal Arcadia scenario through the existing parser: two lifelines, separate call/reply occurrences using one exchange, and an action/state box between them. Use an experimental presentation adapter in a test boundary consumed by the actual Scene geometry; do not relocate the entire renderer or invent production syntax yet.
2. Exercise call movement that displaces the box/reply, zero-motion on the displaced box, movement back, and box-height changes. Record independent numeric anchor expectations and compare actual rendered bounds/line endpoints. Execute a valid source/SetSteps reorder of the independent box and confirm the semantic order, model validation and presentation diagnostic. The semantic Project must remain unchanged during cosmetic previews.
3. Extend that same bounded fixture with one nested operand and a self-message, then a delayed-created lifeline case. Assert send/receive anchors, header attachment, frame containment and child authored-coordinate preservation against the actual Scene/lifecycle outputs. Coordinate with #16 and #21 owners; do not duplicate or replace their implementation.
4. Compare absolute and gap adapters on those identical requested layouts and histories. Capture unmet constraints explicitly; do not auto-normalize source or silently reorder behavior. Select the candidate only after defining its conflict and inverse behavior.
This next experiment can validate real semantic/geometry integration without pretending to prove persistence. Once it passes, the next separate slice is the smallest versioned typed-layout record through parser/formatter, Document transaction, JSON/REST and reopen. Native editing and usability remain subsequent required evidence. Publish the exact experiment source, inputs and results together with this review so remote peers can reproduce the bounded claims.
@@ -0,0 +1,29 @@
{
"status": "expected assertion failure reproduced",
"assertion": "zero-distance pointer operation must preserve authored intent\n+ actual - expected\n... Skipped lines\n\n [\n {\n height: 20,\n id: 'A',\n y: 250\n...\n id: 'B',\n+ y: 280\n- y: 200\n }\n ]\n",
"actual": [
{
"id": "A",
"y": 250,
"height": 20
},
{
"id": "B",
"y": 280,
"height": 20
}
],
"expected": [
{
"id": "A",
"y": 250,
"height": 20
},
{
"id": "B",
"y": 200,
"height": 20
}
],
"scope": "Same toy planner, additional conflict-then-zero-motion history; no production failure claim."
}
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
// Isolated policy comparison; these illustrative metrics are not Studio rules.
const separation = 10;
function absolute(rows) {
let end = -separation;
return rows.map(r => { const y = Math.max(r.y, end + separation); end=y+r.height; return {...r,y}; });
}
function gaps(rows) {
let end=0;
return rows.map((r,i) => { assert(r.gap >= (i?separation:0)); const y=end+r.gap; end=y+r.height; return {...r,y}; });
}
function encodeGaps(rows) {
return rows.map((r,i)=>({...r,gap:r.y-(i?rows[i-1].y+rows[i-1].height:0)}));
}
const positions = rows=>rows.map(r=>r.y);
function moveAbsolute(rows,index,requested) {
const current=absolute(rows);
const floor=index?current[index-1].y+current[index-1].height+separation:0;
const candidate=rows.map((r,i)=>({...r,y:i===index?Math.max(requested,floor):r.y}));
return {candidate,preview:absolute(candidate)};
}
function changeCount(before,after,key) {return before.reduce((n,r,i)=>n+(r[key]!==after[i][key]),0);}
const initial=[{id:'A',y:100,height:20},{id:'B',y:200,height:20}];
const initialG=encodeGaps(initial);
const cases=[];
function named(name,a,g,expectedA,expectedG) {
assert.deepEqual(positions(a),expectedA,name+' absolute');
assert.deepEqual(positions(g),expectedG,name+' gaps');
cases.push({name,absolute:positions(a),gapsWithoutInverse:positions(g)});
}
named('move A to50',absolute([{...initial[0],y:50},initial[1]]),gaps([{...initialG[0],gap:50},initialG[1]]),[50,200],[50,150]);
named('move A to250',absolute([{...initial[0],y:250},initial[1]]),gaps([{...initialG[0],gap:250},initialG[1]]),[250,280],[250,350]);
named('resize A to80',absolute([{...initial[0],height:80},initial[1]]),gaps([{...initialG[0],height:80},initialG[1]]),[100,200],[100,260]);
named('resize A to120',absolute([{...initial[0],height:120},initial[1]]),gaps([{...initialG[0],height:120},initialG[1]]),[100,230],[100,300]);
named('source reorder B A',absolute([initial[1],initial[0]]),gaps([initialG[1],initialG[0]]),[200,230],[80,200]);
named('delete A',absolute([initial[1]]),gaps([initialG[1]]),[200],[80]);
assert.deepEqual(positions(moveAbsolute(initial,1,110).preview),[100,130]);
let feasible=0,moves=0,absoluteChanges=0,gapChanges=0;
const started=performance.now();
for(const ha of [10,20,30])for(const hb of [10,20,30])for(const hc of [10,20,30])
for(let a=0;a<=40;a+=10)for(let b=a+ha+10;b<=90;b+=10)for(let c=b+hb+10;c<=140;c+=10) {
const rows=[{id:'A',height:ha,y:a},{id:'B',height:hb,y:b},{id:'C',height:hc,y:c}];
assert.deepEqual(positions(absolute(rows)),[a,b,c]);
assert.deepEqual(positions(gaps(encodeGaps(rows))),[a,b,c]);
feasible++;
for(let i=0;i<3;i++)for(const delta of [-50,-20,0,10,40]) {
const requested=rows[i].y+delta, {candidate,preview}=moveAbsolute(rows,i,requested);
const independent=[];
for(let j=0;j<3;j++) {
const floor=j?independent[j-1]+rows[j-1].height+10:0;
independent.push(Math.max(j===i?requested:rows[j].y,floor));
}
assert.deepEqual(positions(preview),independent);
assert.deepEqual(positions(absolute(preview)),independent);
assert.deepEqual(positions(absolute(JSON.parse(JSON.stringify(candidate)))),independent);
const encoded=encodeGaps(preview);
assert.deepEqual(positions(gaps(encoded)),independent);
assert.deepEqual(candidate.map(r=>r.id),['A','B','C']);
for(let j=0;j<i;j++) assert.deepEqual(candidate[j],rows[j]);
absoluteChanges+=changeCount(rows,candidate,'y');
gapChanges+=changeCount(encodeGaps(rows),encoded,'gap');
moves++;
}
}
// Independent event/frame attachment probes: no actual Scene/activation engine.
const selfCall=absolute([{id:'call',y:250,height:24},{id:'reply',y:320,height:0}]);
assert.equal(selfCall[0].y+24,274);
assert.equal(selfCall[1].y,320);
const children=[{y:160,height:20},{y:260,height:20}];
const requiredFrameBottom=Math.max(...children.map(r=>r.y+r.height))+20;
assert.equal(requiredFrameBottom,300);
assert.equal(Math.max(300,requiredFrameBottom),300);
assert.equal(Math.max(220,requiredFrameBottom),300);
assert.deepEqual(children,[{y:160,height:20},{y:260,height:20}]);
assert.equal(220-90,130); // delayed header top from moved create receive and header height
const conflicting = moveAbsolute(initial,0,250);
const zeroMotion = moveAbsolute(conflicting.candidate,1,conflicting.preview[1].y);
assert.deepEqual(zeroMotion.candidate, conflicting.candidate, 'zero-distance pointer operation must preserve authored intent');
const result={
scope:'Illustrative linear policy and anchor experiment; no production parser, REST, Scene, GUI or full fragment/lifecycle acceptance.',
separation, cases, feasibleLayouts:feasible, moveCases:moves,
changedAuthoredCoordinateRecords:{absolute:absoluteChanges,gapsWithInverse:gapChanges},
elapsedMs:performance.now()-started,
assertions:'passed',
interpretation:'Both policies express all enumerated feasible layouts. Absolute coordinates preserve unaffected positions with fewer authored coordinate changes under these fixtures. Choose no production schema from this alone; full scope/anchor/inverse and source lifecycle validation remain required.'
};
await writeFile(new URL('./results.json',import.meta.url),JSON.stringify(result,null,2)+'\n',{flag:'wx'});
export default result;
@@ -0,0 +1,79 @@
{
"scope": "Illustrative linear policy and anchor experiment; no production parser, REST, Scene, GUI or full fragment/lifecycle acceptance.",
"separation": 10,
"cases": [
{
"name": "move A to50",
"absolute": [
50,
200
],
"gapsWithoutInverse": [
50,
150
]
},
{
"name": "move A to250",
"absolute": [
250,
280
],
"gapsWithoutInverse": [
250,
350
]
},
{
"name": "resize A to80",
"absolute": [
100,
200
],
"gapsWithoutInverse": [
100,
260
]
},
{
"name": "resize A to120",
"absolute": [
100,
230
],
"gapsWithoutInverse": [
100,
300
]
},
{
"name": "source reorder B A",
"absolute": [
200,
230
],
"gapsWithoutInverse": [
80,
200
]
},
{
"name": "delete A",
"absolute": [
200
],
"gapsWithoutInverse": [
80
]
}
],
"feasibleLayouts": 3555,
"moveCases": 53325,
"changedAuthoredCoordinateRecords": {
"absolute": 37134,
"gapsWithInverse": 61371
},
"elapsedMs": 24777.856600000057,
"assertions": "passed",
"interpretation": "Both policies express all enumerated feasible layouts. Absolute coordinates preserve unaffected positions with fewer authored coordinate changes under these fixtures. Choose no production schema from this alone; full scope/anchor/inverse and source lifecycle validation remain required."
}
@@ -0,0 +1,13 @@
# In-progress navigation source and behavior editing reviews
This commit makes the reviewed source and reports available to sessions on other machines. Local `.local` paths in prior issue comments were inaccessible handoffs; use these versioned files and their relative source links.
The source was copied from the 561-input frozen Windows test snapshot on branch `codex/diagram-usages-ytr83r-20260912`, based on `7aa007b0777aa182a99f46e90eeb9b2558f3ad2a`. Every copied input was SHA-256 verified against the pre-gate capture. This is unaccepted work in progress: the desktop test binary is still compiling at publication preparation, no final tests/lint/native pass is claimed, and known navigation selection/coverage corrections remain. The reports are proposals and independent source reviews, not acceptance results. Linux/WSL remains deferred.
- [Nested activation review for issue21](issue21-activation-analysis-20260912.md): peer956 retains implementation ownership.
- [Free behavior canvas proposal](free-behavior-canvas-design-20260912.md).
- [Independent canvas design critique](free-behavior-canvas-critique-20260912.md).
- [Remaining navigation coverage and selection review](diagram-navigation-remaining-coverage-20260912.md).
- [Frozen source input manifest](frozen-inputs.json).
The experiment, reviews, source paths, and runtime evidence from this machine must be published before they are used as cross-machine handoffs. In-thread delegated agents share the local workspace; independently running Gitea peers do not. Live ownership, plans, and results remain in Gitea issues.
@@ -0,0 +1,93 @@
# Remaining diagram navigation coverage: verified correction seams
Read-only review, 2026-09-12. Scope: #14/#17 in `.local/diagram-usages-ytr83r-20260912`; current frozen, uncommitted working sources, not a release acceptance claim. No production changes, compilation, tests, UI interaction, or Gitea writes were performed by this review. Root owns integration and verification. Paths below are relative to that worktree.
## Decision
Both gaps are actual representations omitted by the current query. Preserve the current render/projection rules, and add explicit target identities plus presentation highlighting. A caller-preview arrow is not identified by the underlying relation ID alone. An allocation grouping's selected semantic object must remain the Allocation relation, independently of the work placement used for focus/highlight.
Issue requirements: [#14](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/14) requests textual-symbol diagram references and jumps to the corresponding element; [#17](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/17) requests equivalent tree navigation. Peer boundaries were read directly in [#3 comment 616](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3#issuecomment-616) and [comment 627](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3#issuecomment-627). Peer confirms the direct-relation/visible-ancestor predicate below, requires separate semantic versus placement identity for Delete, and requires retiring gesture/context lifetimes even for equal-valued navigation. Peer test evidence is not evidence for this worktree.
## A. SequenceLink arrows in caller previews
### Verified representation
- `crates/oa-desktop/src/behavior/topology.rs:904`: `sequence_links` returns actual SequenceLink relations whose two contributions belong to the requested process/chain.
- `topology.rs:914` (`GraphNode`), especially `:922`: `preview_links` stores only `(OccurrenceEndpoint, OccurrenceEndpoint)`. The relation ID is lost, unlike `GraphEdge.id` at `:942`.
- `topology.rs:1094` and `:1128`: each SequenceReference obtains the same bounded qualified preview tiles used by painting, then includes the reused definition's links only when both exact endpoints with `references = [caller_reference_id]` occur in the visible preview set. The normal bound is four tiles; expanded references allow 512. It does not add arrows for every deeper nested definition merely because nested tiles have been expanded.
- `crates/oa-desktop/src/behavior/topology/contextual.rs:253`: preview traversal resolves qualified endpoints through `TopologyIndex`; it preserves each caller path and applies the visibility limit.
- `topology.rs:1568`: the painter draws every retained preview link from the source tile's right center to the target tile's left center. Tile rectangles are then painted opaquely at `:1581`. Therefore the arrow's visible portion can be smaller than its raw line segment. These are real painted arrows, distinct from `Graph.ordered_paths`, whose explicit contract says they are projected adjacency without persisted relation IDs.
- `topology.rs:926`: `endpoint_rect` has a whole-reference proxy fallback. A usages query must continue rejecting that fallback, as existing `usages.rs:81` does for qualified tiles.
- `crates/oa-desktop/src/behavior/topology/usages.rs:89` and `:123`: current relation lookup considers `graph.edges`; it never scans `node.preview_links`. Thus a real caller arrow is absent even when the same relation has a normal link usage on its definition surface.
- `crates/oa-desktop/src/behavior/topology/direct.rs:540`: edge hit testing is also restricted to `Graph.edges`. `:105` and `:111` implement occurrence context, not qualified relation selection. `topology/view.rs:179` copies that occurrence context into `graph.selected_preview`; `topology.rs:1583` highlights tiles with it. Reusing that context to mean an edge would conflate authoring semantics.
- `crates/oa-core/src/validation.rs:2437` forbids SequenceLink self endpoints; `:2453` requires contributions directly owned by the same process/chain. Navigation should nevertheless reject missing/degenerate geometry, rather than using an enclosing box fallback.
### Minimal correction
1. Replace the preview endpoint pair with a small record retaining actual relation ID and the same two qualified endpoints. Populate it from the existing filtered relation loop. Do not add traversal, widen preview limits, or change which arrows are painted.
2. Add a target such as `ProcessPreviewLink { relation, source: OccurrenceEndpoint, target: OccurrenceEndpoint }`. The enclosing `Surface::Process(owner)` plus these fields distinguishes repeated caller instances, parallel relations, and retargeting. The existing unqualified `ProcessLink(Id)` remains appropriate for normal graph edges.
3. Share the record's exact arrow geometry between painting and usages. A midpoint of the raw segment may fall under an opaque tile; use a visible segment/gap for focus. A small helper can subtract the preview tile rectangles from the straight segment and choose a remaining segment. Require some visible nondegenerate geometry; never manufacture a row from endpoint membership alone when no arrow is emitted. This helper may be tested without changing routing or the visual layout.
4. Query the actual retained records, with `subject == record.relation`. The chooser label must include the caller path, and its role should distinguish a preview sequence link. Do not emit links for ordered-path adjacency, arbitrary nested source links, or referenced work merely because it is an endpoint.
5. Add dedicated transient preview-link presentation selection, then paint exactly that record with a selected stroke. Keep `App.selected = relation`, and clear occurrence selection for this target. Do not put an edge into `selected_context`, which is validated as an occurrence and feeds contribution operations. New canvas authoring/hit-test behavior is not necessary for the navigation fix.
6. Revalidation uses the existing one-surface query and exact target equality. Both endpoints must still resolve and the same record must still be emitted under the current expansion snapshot. Reopening after a changed accepted revision may refresh; the stale Choose event must not jump. Existing asynchronous snapshot behavior remains sufficient: no draft, camera, or selection state needs to cross the worker boundary.
### Independent expectations
- A definition's link rendered in two caller invocations yields two distinct caller targets plus its normal definition target. Choosing either keeps semantic selection equal to the relation ID while highlighting and centering the correct qualified arrow.
- A link touching the fifth hidden tile has no default caller row. Expanding only the first invocation adds that invocation's arrow, not its sibling's. Collapsing or retargeting invalidates the old target.
- Deeper expanded tiles alone do not cause usages for deeper links that the existing painter never emits. Ordered adjacency without an actual SequenceLink remains absent.
- Parallel relations retain distinct relation identities even if current preview geometry overlaps. Reverse/cross-row links focus a visible line segment, not an opaque tile interior.
- Update `topology/usages/tests.rs`'s current `process_contextual_purpose_is_a_visible_reference_but_preview_arrows_are_not_links` expectation specifically for retained preview records; preserve its exclusion of other unrepresented links.
- Exercise actual App update with open/completion/choose, old token/revision/document, reference retargeting, modal/draft protection, repeat choice, and preserved Project/SourceBundle/revision/dirty/history. Assert exact qualified selection and bounds, not merely that the destination page opened.
## B. Allocation relations represented by grouping
### Verified representation
- `crates/oa-core/src/projection.rs:81`, `parents`: Allocation projection computes actual placement parents using the effective allocation summary and nearest placed coherent work ancestors, with performer fallback. This is visual containment, separate from semantic ownership.
- `projection.rs:274` projects compatible placed nodes. Around `:300`, a collapsed ancestor or cycle prevents a descendant from entering visible `view.nodes`. Around `:331`, the resulting node records `visual_parent`; `:350` encloses containers with projected header geometry.
- `projection.rs:393`: Allocation relations are intentionally suppressed as arrows in Allocation projection. Looking only at projected edges cannot find their grouping representation.
- `projection.rs:331` and `crates/oa-core/src/validation.rs:2812`: allocation summary is explicitly aggregate display information, never an inherited Allocation relation. A summary node without a direct incoming Allocation is marked for dashed presentation. This does not authorize generating a relation usage.
- `crates/oa-desktop/src/diagram.rs:1015` derives visible containers from `visual_parent`; `:1021` paints containers before edges/children. `:1129` repaints container headers above traversing relationships. `:1452` paints nodes; selection is based on `element.id == selected`. Summary styling is separate, including `node_header` at `:1509`.
- `crates/oa-desktop/src/app.rs:718` (`selected_architecture_placement`) derives a placement from `App.selected`; `refresh_draft` immediately recomputes `selected_node` from it. `app/diagram_usages.rs:380` currently writes `selected_node`, but calls `refresh_draft` at `:395`. Setting relation semantic selection plus `selected_node = work_placement` alone therefore does not preserve focus, and the current renderer would not highlight that work. The same architecture seam applies to ports: `Target::Port` sets semantic selection to the port and owner placement separately, then draft refresh cannot find a node whose element is the port and clears `selected_node`. Root identified the existing `port_navigation_selects_port_and_exact_visible_owner_without_editing_geometry` expectation as affected while the frozen gate runs; this review did not execute it. State rows initially set `behavior.diagram.placement` (`app/diagram_usages.rs:330`), but the rest of `refresh_draft` (`app.rs:734`) recomputes that field using the same selected-node/element comparison when `state_context` resolves. Thus relation selection can clear that exact placement too. The renderer can still paint a row from selected relation identity; placement reset alone does not prove a visible row-highlight failure. Preserve and verify both semantic selection and the intended exact presentation identity.
### Confirmed predicate and minimal correction
For an actual direct `RelationKind::Allocation` r, in a saved Allocation projection only:
1. Obtain actual visible projected nodes whose element IDs are r.source (performer) and r.target (work).
2. Walk the target work placement's `visual_parent` chain through visible projected placements with a visited set.
3. Emit a grouping usage only if that chain contains the source performer placement. Retain the actual relation ID and the exact work and performer placement IDs.
This is precisely the peer-approved comment 616/627 predicate. It is not inferred from `allocation_summary`, two visible endpoints, a decomposition parent, or arbitrary semantic containment. Do not change core allocation validation, `parents`, drag/reparent commands, persistence, or peer Delete behavior.
Use an explicit target such as `AllocationGrouping { relation, work_placement, performer_placement }`, with role `allocation grouping` and the projected work's bounded labelled-header rectangle. Reuse the existing bounded node-header camera policy. This target makes the distinction reviewable; neither `Placement(work)` nor an unrelated `StateRow` target expresses it correctly.
Keep selected semantic identity equal to the queried relation. Add a separately validated presentation focus/highlight for the exact work placement, surviving `refresh_draft`; pass it to the architecture renderer independently of the semantic selection. Prefer a typed focus carrying document, revision, diagram, relation, placement, and navigation epoch, so normal selection/document changes cannot reuse stale focus. An alternative is deriving highlight from selected relation and projected grouping each paint; that avoids stored focus but needs proof it preserves exact placement when multiple representations exist. Do not silently change selection to work to reuse the current stroke logic. Use the same separation for architecture ports, while preserving state-view placement semantics. Do not blindly restore `selected_node` after refresh: coordinate its edit/Delete meaning first. Independent App tests should assert the exact validated visual marker (port owner, grouping work, or state row) and unchanged semantic target, rather than requiring an overloaded global edit-selection field.
Coordinate this presentation focus with the peer's Delete capture: removal continues to capture the selected relation as semantic target; focus placement is separate context, never a replacement semantic target. Explicit later user selection of work can of course change the target. Preserve `app/diagram_usages.rs:382`'s unconditional canvas epoch increment, menu closure, bend/connection reset, and every pending-draft guard, including navigation returning to equal values. Extend deferred reveal to validate the same grouping target; its temporary pending tuple is consumed after layout and should not double as persistent highlight state.
### Independent expectations
- A visible direct allocation with the performer's placement on the actual work ancestor chain produces one grouping row and focuses that exact work header, even when the performer is not its immediate visual parent.
- Hidden/folded work, hidden performer, wrong projection, endpoints that are merely both visible, a mismatching visual-parent chain, and aggregate-only summary produce no direct Allocation usage.
- A direct child allocation underneath a summary container remains discoverable if its target is visible and its performer occurs in the chain. No extra relation is invented for the summary container.
- Choosing preserves the complete model/source/history and leaves `App.selected == relation`; highlighting work does not make a subsequent Delete capture target work. Test this against peer's integrated removal contract, not by assuming current adapter state alone proves it.
- Fold/delete/retarget/diagram replacement invalidates an old grouping target. Repeat navigation retires old canvas/context tickets even when destination/selection values compare equal. Both initial and deferred camera reveal reject stale document/revision/epoch.
## Integration boundary and source identity
Suggested bounded production seams: `diagram_usage.rs` target/query/label; `behavior/topology.rs` preview record/geometry/paint; topology `usages.rs` query/reveal and `view.rs` selection wiring; minimal topology presentation-state field; App adapter and architecture canvas presentation-focus wiring. Root coordinates shared App/view changes and peer Delete compatibility. No model/schema/dependency or allocation-authoring changes are required.
Verified SHA-256 snapshots (raw file bytes):
| File | SHA-256 |
| --- | --- |
| `behavior/topology.rs` | `dff564d2dca68af02874878cabdabdcb7f717bff67b221d376ccb6734a2e16ae` |
| `behavior/topology/usages.rs` | `d1ff2f16695d560bd2676825eb6c11d82b0c259cc84d6804dc2dbd1a8dadc42c` |
| `diagram_usage.rs` | `11ed6c844148eba6a122c675168561e9e9dc4e88399ae4fc0facfb82d79bdd89` |
| `app/diagram_usages.rs` | `2374a9323bf422c8cb1af7832aa2c3ce3294a894f1fc710ba165c557b26d8c8b` |
| `oa-core/src/projection.rs` | `69ed12bace2cc5024e151752a174ab14034ac2d05eb741fcf13e9a27521c059b` |
| `oa-core/src/validation.rs` | `7c1b4ae5e4ebba13e99af9158ccde138dfc5e9a7afb580e76fba72d0b64ba74e` |
Line references describe these inspected working files. Root's in-progress checks cover the frozen implementation before these proposed corrections, not the proposal. Root should implement after the freeze, run focused query/App tests plus required project checks, and inspect actual repeated-call arrow and allocation-grouping navigation with semantic selection/Delete capture before accepting coverage.
@@ -0,0 +1,104 @@
# Independent critique: freely editable behavior canvases
2026-09-12. Reviewer `/root/diagram_usage_workflows`, delegated by Codex-ytr83r. Read-only review of current issues #2/#20/#22, the proposal in [free-behavior-canvas-design-20260912.md](free-behavior-canvas-design-20260912.md), and primary code in `.local/diagram-usages-ytr83r-20260912`, branch `codex/diagram-usages-ytr83r-20260912`, base `7aa007b0777aa182a99f46e90eeb9b2558f3ad2a` plus root's uncommitted navigation changes. No production edit, build, GUI operation, or Gitea mutation. This is design evidence, not native acceptance. Root owns synthesis; peers retain #16 resize and #21 activation rendering ownership.
## Actionable conclusions
1. **Conditionally adopt the existing Diagram identity/lifecycle, not the entire proposed implementation shape at once.** Owner-bound Process and Scenario projections fit saved views and avoid a second navigation/source/API aggregate. Use a single tagged behavior-layout payload, or an equally strict validated representation, rather than unrelated optional process/scenario blocks. Preserve the existing architecture/state storage contract. Do not make moving every desktop geometry algorithm into core a prerequisite for the first useful slice.
2. **Resolve the projection/gesture contract with a bounded numeric experiment before committing to the scenario schema.** Absolute desired anchors are a credible candidate; stored and resolved geometry legitimately differ already in architecture/state. What remains undefined is conflict resolution, the inverse from a requested drag/resize to authored changes, and what a text-only user sees after reorder. The table below makes this uncertainty falsifiable. This is a policy prerequisite, not a categorical rejection of desired coordinates or permission to postpone the feature indefinitely.
3. **Include legacy ordered process paths in the route contract.** Requiring an explicit SequenceLink before bending an already drawn legacy arrow fails the user's bend request for those existing models. Add a typed derived-path address and a defined lifecycle. Never manufacture semantic links as an elbow-drag side effect.
4. **Retain relation identity in qualified previews before saving their routes.** Current preview links retain only endpoint pairs. Parallel links would collide if those pairs became the persistence key. A qualified route must identify both the full caller path and the relation. The same leaf reached through different paths must remain independently editable.
5. **Do not implement first-edit capture as a screenshot-to-model copy.** It must seed valid authored inputs whose projected result equals the preview, preserve omitted/invisible records, and allocate view/placement identities once. Peer #16 already supplies a concrete counterexample to copying rendered dimensions back as authored dimensions. Complete capture also needs a size/performance bound and a stable policy for missing or collapsed appearances.
6. **Specify reference transformations and source comment keys before UI fan-out.** Owner deletion/change, reidentify, caller-target changes, source reorder, legacy-to-explicit conversion, and hidden routes are not incidental cleanup. They determine whether manual work survives normal editing. Two views of one owner must be part of the first identity tests.
## Requirements and verified evidence
The current user clarification in [#2 comment608](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-608) rejects the camera-repair milestone as satisfying free behavior editing. [#20](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/20) requires spreading lifelines to make call names readable, moving arrows, resizing, and replacing strange controls. [#22 comment606](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/22#issuecomment-606) specifically adds process connection bends. Earlier sequence-constraint discussion does not erase these requirements. Cosmetic manipulation should preserve semantics; graphical/text/API parity follows the user-owned project specification. Always-present canvas and ordinary Properties are the agreed direction, not permission for canvas keyboard actions behind a focused text field.
Verified local constraints:
- [model.rs:1431](../../../crates/oa-core/src/model.rs#L1431): ProjectionKind is Copy and currently has only a StateMachine owner variant. Diagram contains common metadata, view options, ID-keyed NodePlacements and relation/trace EdgeRoutes. Keeping owner IDs in ProjectionKind while storing layout separately avoids a needless Copy-breaking change.
- [validation.rs:645](../../../crates/oa-core/src/validation.rs#L645): routes must refer to existing relations/traces, are unique per edge, have bounded finite waypoints and normalized label fractions. [validation.rs:774](../../../crates/oa-core/src/validation.rs#L774) enforces one placement per semantic element per diagram. Repeated qualified appearances therefore cannot simply be extra ordinary nodes referencing the same leaf.
- [topology.rs:914](../../../crates/oa-desktop/src/behavior/topology.rs#L914): GraphNode previews carry OccurrenceEndpoints; preview_links are only endpoint pairs. OrderedPath explicitly has source/target/points and no relation ID. [topology.rs:1128](../../../crates/oa-desktop/src/behavior/topology.rs#L1128) drops each preview relation's ID while constructing those pairs.
- [topology.rs:1000](../../../crates/oa-desktop/src/behavior/topology.rs#L1000): Graph rebuilds a TopologyIndex, derives rank from explicit links plus ordered adjacency, then constructs geometry. The rank traversal filters the adjacency list for each queued node. This is a concrete existing repeated-scan risk; adding stored layouts should not multiply this by every saved view on every pointer move.
- [behavior.rs:1393](../../../crates/oa-desktop/src/behavior.rs#L1393): scenario rows follow steps/branches and recursively accumulate geometry. Fragment and branch boxes have different anchor conventions. A message y, frame rectangle and branch extent need a deliberate canonical relationship.
- The separate [#21 read-only activation analysis](issue21-activation-analysis-20260912.md) verifies self-message receive geometry below its row anchor and existing lifetime/branch attachment concerns. This foundation must expose send/receive anchors to the activation owner; it must not implement a competing activation engine.
## Storage alternatives against the actual criteria
| Candidate | Manual freedom and identity | Source/API/lifecycle cost | Main falsifier | Recommendation |
| --- | --- | --- | --- | --- |
| Shared Diagram plus independent optional process_layout/scenario_layout | Expressive; keeps saved-view identity | Small additive syntax, but many invalid combinations and duplicated authority | Scenario projection with process layout or generic unrelated nodes passes unnoticed; two records claim one direct appearance | Avoid this public contract unless strict cross-field validation is explicit and exhaustive |
| Shared Diagram plus tagged BehaviorLayout, projection retains owner ID | Expressive; common metadata/navigation/history retained; domain records typed | Moderate schema/parser/walker work; owner/tag compatibility still needs validation | Tag/owner mismatch, same address in generic and qualified records, incompatible fields retained after projection change | Preferred bounded shape; constructor/accessor invariants plus parser/API validation |
| Behavior layout stored inside a payload-carrying projection enum | Stronger type coupling possible | Changes Copy assumptions and a widely matched enum; can make common projection queries expensive | Large ripple before first persisted move; geometry cloning during inexpensive kind checks | Viable only if implementation evidence makes it simpler; not needed merely for type purity |
| Separate top-level BehaviorView collection | Clean discriminated domain structure | Duplicates naming, identities, references, source ownership, REST inventory, selection, recovery, where-used and view deletion | A process view cannot participate in ordinary diagram navigation/export/history without a second branch everywhere | Keep as fallback if common Diagram validation becomes unmanageable, not first choice |
| Session-only map or spacing sliders | Quick visible result | Cannot provide durable source/REST/reopen parity | Save/reopen loses edits; an individual bend or position remains impossible | Rejected as production feature; candidate preview remains transient |
Minimal recommended boundary: common Diagram ID/metadata; Process/Scenario owner tags; ordinary direct-process nodes and explicit relation routes; typed extra addresses only where the existing representation cannot express identity. A tagged Process payload can hold qualified placements and derived adjacency routes. A tagged Scenario payload can hold lifeline/event/frame/message geometry. Empty ordinary nodes/routes should be required where they have no defined scenario meaning. Do not relax the global one-element-per-diagram invariant just to fit qualified appearances.
Define whether an empty qualified path is illegal or canonicalizes to the ordinary direct occurrence. Allowing both forms creates two geometry authorities. Prefer rejecting the empty path in the qualified-only collection. Presentation records keyed by semantic addresses need not invent global UUIDs, but any new placement UUIDs must join identity/reidentify/alias/serialization walkers consistently. Neither indexes nor displayed labels are persistent addresses.
## Numeric experiment for constrained scenario geometry
These are deliberately simplified independent examples, not production metric claims. Let origin=0, minimum gap=10, A and B each have height20, order=[A,B], displayed tops A=100/B=200. Compare (D) absolute desired tops plus the illustrative monotone projection `resolved[i] = max(desired[i], previous_bottom + 10)` and (G) nonnegative gap-before stored on each occurrence, initially gap(A)=100/gap(B)=80. Neither example algorithm is adopted by this report.
| Operation | D with no inverse adjustment | G with no inverse adjustment | What the experiment must decide/prove |
| --- | --- | --- | --- |
| Move A to50 | A50/B200 | A50/B150 | Ordinary move should be able to preserve B at200; G needs gap(B)=130. Test a planner that changes only necessary dependent records. |
| Move A to250, preserve order | A250/B280; B's desired200 remains displaced | A250/B350 | Both preserve semantics, but select different whitespace policies. Preview must show B's actual final position. A feasible requested layout A250/B280 must be expressible in both (G gap(B)=10). |
| Move B upward to110 | B resolves130; stored110 differs | Requested negative/too-small gap needs defined clamp or rejection | Report a concrete constraint and preview actual B130; never commit one pixel result and reopen another. Explicitly distinguish desired values from resolved inspector values. |
| Increase A height20→80 | A100/B200 remains feasible | A100/B260 | Resize must be able to preserve B at200 when feasible (G gap(B)=20). Inactive dimensions and unaffected coordinates should not drift accidentally. |
| Increase A height20→120 | A100/B230 | A100/B300 | Minimum-gap conflict now exists. Show which rows move and why; preserve authored inactive axes. A source/API resize must produce the same projection as the GUI candidate. |
| Source reorder to[B,A] | B200/A230, A desired100 displaced | B80/A200 if each gap travels with its occurrence | Both are deterministic yet materially different. Specify whether whitespace belongs to an occurrence, a predecessor pair, or a sequence boundary. No passive rewrite or semantics-from-y. |
| Delete A | B200 | B80 | This is a lifecycle choice, not a numerical accident. Preserve surviving absolute position or deliberately remove preceding whitespace; GUI/source/API must agree. |
The test harness should enumerate small feasible integer layouts and check every requested feasible result has an authored representation and a stable projector result. Test repeat projection idempotence, no hidden model/source mutation, and planner preview = committed projection = source round trip = JSON/REST round trip. Measure changed records as well as output positions; equality from calling the same solver twice is insufficient. Independently assert the expected selected coordinate, semantic order and unchanged unaffected positions.
Additional discriminating probes:
- Nested fragment F spans y80..400, operand starts120, A160 and B260. Resize only F's bottom to300, then to220. Define header, padding, operand and child containment constraints. The first feasible result must preserve authored children. The second must report/preview specific container growth, constrained bound or chosen child movement; silently clipping children or reparenting them is invalid. Repeat moving F with and without an explicitly named group-move action, specifying absolute versus translated child coordinates.
- Self-call send at200/receive at224, reply later at320: move the call to250 and change its loop height independently. Send, receive, activation start and reply attachment must consume the same anchor outputs. A single `message.y` is sufficient only if the receive-offset convention is explicitly part of projection/route geometry. Interior waypoints must not accidentally change the semantic receiver or activation interval.
- A delayed-created lifeline must attach its header to its creation receive anchor after message movement; deletion must end the lane at the deletion receive anchor. Moving a header horizontally never changes sender/receiver. Hidden branch paths must retain stored values without entering a contradictory visible ordering.
- Repeated messages with one exchange and distinct occurrence IDs get independent y/bends/label offsets. An opaque ScenarioUse does not create layout for callee events merely because bindings refer to them.
- Text reorder with conflicting desired anchors must return a deterministic inspectable layout diagnostic containing the affected occurrence addresses and reason. Decide whether the UI displays desired and resolved values, offers an explicit Normalize Layout transaction, or exposes the conflict otherwise. Valid semantics must not be silently rewritten to match pixels.
Recommendation: implement both tiny pure candidate projectors/inverse planners outside production architecture and run these probes. Start with absolute anchors as the likely better fit for exact coordinates and preserving unaffected positions, but select it only after the nested/lifetime probes and inverse-resize counterexample pass. Gap-before remains a meaningful comparison, not an automatic-only caricature: a planner can translate requested absolute positions into gaps. Record measured complexity and source diff size. Bound this experiment to the policy uncertainty rather than broad renderer extraction.
## Reference lifecycle and source contract that cannot remain implicit
| Case | Required independent oracle |
| --- | --- |
| Two saved views of one owner | Change only view V1's layout; V2 and semantics remain byte-for-byte equal. All context stamps, camera caches, navigation and recovery include diagram ID. |
| Reuse same callee twice; nested reuse twice | Route/resize `[caller1,nested1]/leaf` only; caller2 and the callee definition remain unchanged. Parallel callee relations retain separate routes despite equal endpoints. |
| Delete/reidentify caller or occurrence | Reidentify transforms every address component and source comment key; delete removes or explicitly invalidates dependent records in the same validated transaction. Never retarget by name. Undo restores exact keys and geometry. |
| Change a caller's referenced process | Reject incompatible old paths or remove them through an explicit reported transformation. Reuse of a UUID elsewhere must not silently activate an unrelated stale record. |
| Legacy order A,B,C → A,C,B | Derived route(A,B) stops applying; route(A,C) is a different key. Define whether obsolete records are removed, retained dormant, or diagnosed. If retained, state whether reintroducing adjacency restores them. The decision must be bounded and tested. |
| Convert legacy paths to explicit links | Define an atomic presentation route migration by exact endpoint correspondence, with ambiguity handling for parallel links. No automatic semantic conversion during bend edits. |
| Filter/collapse temporarily hides a route/node | Preserve authored records. Partial current visibility must never become a destructive replacement snapshot. Reopen/reexpand restores the same geometry. |
| Change diagram owner/projection/layer | Reject incompatible retained fields, or require an explicit complete conversion in one transaction. No silent geometry loss or unrelated node acceptance. |
| Invalid source/API payload | Reject duplicate typed keys, owner/tag mismatch, scenario event of another owner, empty/invalid qualified path, wrong route subject kind, NaN/infinity/out-of-bounds geometry, and over-budget path/point collections with precise field diagnostics. |
The current version7 strict parsing/serde and validation gates require a coordinated schema version decision, not unversioned optional-field accretion. Old sources should open clean without synthesized diagrams or formatted rewrites. An older reader must reject unsupported new constructs rather than discard manual geometry. Source format/parse, JSON, REST conditional transactions, reference walkers, copy/transition, multi-file ownership and comments all need compatible changes.
Nested comments must be matched by the full typed key: a scenario occurrence, qualified endpoint, qualified relation, or derived ordered-path pair plus its domain. Array index and terminal occurrence alone are insufficient after reorder/reidentify. Same-named aliases in included files must resolve by IDs. Explicit reset of one geometry record must not discard sibling comments or materialize invisible records.
## First edit, scalability, and proportionate implementation
First accepted gesture should create the view and required authored records in one normal transaction. Pointer-down/preview may reserve deterministic candidate IDs, but render, selection and cancel create no source changes. Undo removes the first view creation; redo restores the same IDs and geometry. If a first resize begins from derived geometry, preserve the authored-versus-resolved contract established by #16 instead of serializing every projected width/height.
Complete direct-surface capture protects untouched objects against later seed-layout movement, but has costs: an O(model-size) first edit, larger source diffs, new identity allocation and potential dependence on transient filters. Sparse capture is smaller but leaves untouched nodes dependent on changing ranks/row generation. A bounded compromise is a stable initial placement plan plus explicit overrides, provided that this does not introduce two unexplained coordinate authorities. The first slice should compare total records and diff size on a realistic long model before selecting capture policy. Do not claim that “currently rendered” means a stable semantic set until filter/expansion/default-view rules are fixed.
Keep stored geometry projection pure and reuse geometry primitives. It is not necessary to relocate the entire Scene/Graph renderer before proving persistence. An adapter can resolve typed authored records into current Scene/Graph positions, attachment anchors and routed paths; both preview and committed render must consume that result. Any behavior constraint or inverse planner needed by REST should be UI-free. Do not duplicate a second projector for source/API or retain competing automatic geometry paths that overwrite saved positions late in rendering.
The existing process rank scan can approach V×E work; repeated TopologyIndex rebuilding and qualification resolution add cost. Cache immutable owner/topology indexes per model revision, rebuild affected geometry once per candidate, and bound visible expansion separately from stored-record validation. Validate stored keys without enumerating every possible recursive appearance. Limit total records, aggregate path length/depth and points as well as points per route. A saved view must not force eager expansion of the Cartesian product of nested uses. Stress tests should record first-edit latency, per-pointer candidate work, lookup/reopen work and source size on the same fixture, with an explicit target budget agreed for the actual hardware rather than invented acceptance milliseconds.
## Minimum independent acceptance before broad adoption
1. One owner-bound process view moves a direct occurrence off its rank column, resizes it, bends both an explicit link and an existing legacy path, and moves a label. Links, steps and represented work do not change. Preview/commit/reopen are numerically equal and one Undo restores source/model.
2. Two-lifeline scenario independently spaces headers and positions/routes two occurrences of the same exchange, preserving sender/receiver, message kind, reply_to, steps and lifetimes. Long labels become inspectable through ordinary controls. The numeric source-reorder/resize policy probes above pass.
3. First edit, cancel, zero movement, stale revision, document/view switch, dirty-source navigation, focus loss and modal entry have explicit nonmutation or atomic commit outcomes. Actual App input paths must be exercised later, not only pure command constructors.
4. Two saved views and two qualified appearances retain independent geometry, full target identity and source comments across save/reopen, REST, reidentify, collapse, delete and Undo. An exact usage jump reaches the saved appearance without changing geometry.
5. Nested fragments, operands, self messages and delayed creation/deletion use explicit attachment outputs. Peer #21 activation rendering is reviewed against those outputs; this task neither overwrites its fix nor claims its native acceptance.
6. Actual native workflows at the minimum supported window demonstrate immediate canvas manipulation, visible resize/bend handles, Properties focus, discoverable semantic Reorder, and stable viewport origin. A screenshot or small fixture alone does not establish all process/scenario kinds or large-model usability.
Adopt the common saved-view direction and a bounded end-to-end slice now; settle the numeric projection/inverse and address-lifecycle rules before multiplying UI controls and schema cases. Revisit the common Diagram choice only if the typed payload/validation slice demonstrates more duplication than a separate aggregate, not because domain-specific geometry exists.
@@ -0,0 +1,185 @@
# Freely editable process and scenario canvases — design investigation
2026-09-12. Read-only investigation by delegated reviewer `/root/diagram_membership_map` for Codex-ytr83r. Source inspected in `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/diagram-usages-ytr83r-20260912`, branch `codex/diagram-usages-ytr83r-20260912`, base `7aa007b0777aa182a99f46e90eeb9b2558f3ad2a` plus root's uncommitted navigation/prerequisite integration. This is a proposal, not an implemented feature or an acceptance result. No source modifications, build, GUI operation, or Gitea claim was performed. Root owns compilation and native inspection; peer Codex-956atf owns #16 resize foundation and #3/#15 work.
## Required outcome and evidence boundary
The user's [#2 clarification608](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-608) explicitly rejects the camera/repair preview as satisfying free behavior editing. Process and scenario diagrams must support ordinary direct manipulation comparable to architecture/state diagrams. The earlier comment250 about sequence constraints does not authorize retaining rigid automatic-only layouts after that clarification.
Concrete requirements are [#20](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/20): spread lifelines to expose labels, move arrows, resize objects, replace strange draggable rectangles with understandable controls; [#22](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/22), including comment606: process movement, resizing, and connection bends; [#24](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/24): replace the Details-menu interaction; [#25](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/25): canvas available without an activation button. The user-owned specification also requires graphical/text parity, an unambiguous reST specification, REST automation, readable source, and maintainable architecture. Cosmetic movement is not authorization to change behavioral ordering.
The [peer resize foundation at full commit f7d84faf30cdb5e3695c2dc16ee0a5f2eac91e71](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/f7d84faf30cdb5e3695c2dc16ee0a5f2eac91e71/harness/diagram-resize-foundation-20260912.md) was read, including its executed falsifiers and unresolved questions. Its verified contract is eight handles, fixed opposite edges, unchanged authored child placements/semantic ownership, one Undo transaction, preservation of inactive authored fields, and exact projected preview/reopen equality. It rejects copying every visible dimension back into storage: stored224×160 projects224×232, copying224×232 can project232×232, while preserving authored height permits214×232. It also rejects a universal preferred minimum on an inactive axis. Those are resize-planner requirements, not reasons to omit handles. Process/scenario persistence is explicitly a separate dependency. No new resize-gesture implementation is claimed here.
Existing [native Capella benchmark](../../../harness/release-capella-scenario-benchmark-20260910.md#L1) demonstrates point creation, a drag-created LOOP/operand, and distinct occurrence/definition identities at1010×760. It does not establish resize, message movement, routing, or nested activation behavior. Root's current native capture attempts timed out, so no new native evidence is claimed.
## Current implementation: the missing persistence boundary
| Surface | Actual geometry and interaction | Consequence |
| --- | --- | --- |
| Persisted architecture/state | [`Diagram`, `NodePlacement`, `EdgeRoute`][model] provide stable view/placement identities, absolute coordinates, authored size, routes and labels. `ProjectionKind` has StateMachine but no Process/Scenario owner variant. | Existing source/history/API mechanisms are reusable. Adding process/scenario enum labels alone cannot make their desktop renderers consume saved geometry. |
| Process | [`Graph::expanded_qualified`][graph] derives columns from SequenceLinks/legacy order and places every contribution at `x=24+rank*420`, initial size270×104. Reuse cards get automatic preview height; expansion chooses4 or up to512 tiles. [`State`][topology-state] stores ephemeral camera/expansion/draft state, no persisted layout. | Every render reconstructs positions and routes. No user-authored movement survives because no such geometry exists in the model. Source owner `steps` determines included occurrences, not coordinates. |
| Process input | [`direct::Graph::input`][process-input] selects from node bodies. A separate [`handle`][process-handle] starts `Press::Reorder`; release publishes Reorder, not Move. The planner rejects cross-column reorder because links define columns. Reconnect handles alter semantic links; there is no bendpoint drag. | The strange rectangle is a semantic reorder affordance, not a misplaced implementation of free movement. Renaming it or making it larger cannot satisfy #22. |
| Qualified process reuse | [`contextual::preview`][context-preview] and `GraphNode.preview` retain caller paths plus occurrence IDs. Direct ContextualLinks retain relation IDs; preview links currently reduce callee links to endpoint pairs. | Two appearances of one callee occurrence must have independent layout. Saving by represented work ID or callee occurrence ID alone will alias them. Preview routes need the complete caller path plus callee relation ID before they can be authored. |
| Scenario | [`Scene::sequence`][scene] gathers direct lifelines, places them at automatic x positions (160+i*240), creates fixed204×70 headers, and [`occurrences`][scene-occurrences] walks semantic sequences/operands to build fixed row spacing, boxes, arrows and frames. Lines are keyed by exchange **occurrence** ID. [`Presentation`][presentation] stores only identity, viewport, focus/shelf and camera. | Lifeline spacing, event y, box/frame dimensions and route bends are not model data. Reopening cannot restore manual layout. Repeated messages using one exchange still require separate presentation. |
| Scenario input | [`Scene::reorder_handle`][scenario-handle] creates a22×24 rectangle at x6 beside the selected row. [`scenario::reorder`][scenario-reorder] changes `SetSteps` within the current owner/operand only. | The existing drag is an ordered-list edit. It does not represent free line movement, independent whitespace, or shape sizing. |
| Scenario lifetime/activation | [`scenario_lifecycle`][lifecycle] derives birth/death/call semantics, with rendering consuming occurrence rows/sequence bounds. ScenarioUse is currently an opaque card; bindings do not produce inlined callee rows. | Geometry must consume semantic lifetimes and ordering; it must not derive new semantic call/death relations from drawn coordinates. Nested activation #21 remains a separate semantic/rendering dependency. |
| Shared shell | [`controls`][controls] has Details and Canvas buttons; `keyboard_enabled` and `Scope::input_owner` gate draft/modal contexts. [`host`][host] overlays a300px editor over the viewport. | Replace the activation/Details UX, but preserve actual keyboard ownership and stale-context protections. Always-active pointer editing is compatible with text inputs owning their keyboard. |
The authoritative existing presentation specification is [language.rst section5][language-diagrams]. Ordinary `node` coordinates are absolute diagram coordinates and do not alter containment/allocation. `routes.edge` currently accepts a Relation or Trace, never a scenario exchange occurrence. Route list order/comments are retained by identity; endpoints and automatic elbows are deliberately not saved. [`Command`][commands] already has Create/EditDiagram, Create/EditNode, MoveNode, Set/RemoveEdgeRoute, SetSteps, Batch and Layout. [`Document::execute`][document] prepares a complete candidate, updates source with the identity map, and commits the accepted model/source pair together. [Server routes][server] expose conditional project transactions; adding layout must use this pathway, not a second desktop-only store.
## Alternatives and proposed choice
Criteria: satisfy manual placement/resizing/bends; preserve behavioral semantics during cosmetic edits; exact source/API/Undo/reopen parity; retain existing authored architecture/state geometry; use stable identities for repeats; keep the interaction understandable; avoid a broad rewrite before a useful end-to-end workflow exists.
| Candidate | Advantages | Material failure or cost | Decision |
| --- | --- | --- | --- |
| More camera controls, auto-layout spacing sliders, wider fixed cards | Small desktop diff; preserves current generator | Still cannot place individual objects/routes; explicit user rejection already falsifies sufficiency | Reject as the feature; optional layout assistance only |
| Desktop/session position overrides | Fast visual prototype | Loses source/REST/save parity; undo splits between model and view; stale/reused identities become ambiguous | Reject production storage; transient drag preview is its only role |
| Put all behavior in an unconstrained generic graph, with free x/y treated as semantic order | Broad shape reuse | Cosmetic drags can change execution; sequence endpoints, lifecycle, operands and nested calls require special treatment anyway | Reject semantics-by-coordinate; reuse primitives only |
| New independent BehaviorDiagram top-level aggregate | Clean domain separation and typed layouts | Duplicates view naming/identity/source ownership/artifacts/navigation/server inventory; more migration and integration than needed | Viable if Diagram becomes unmaintainable, but not smallest coherent first choice |
| Extend existing Diagram with owner-bound Process/Scenario projections and typed domain layout | Reuses stable saved-view lifecycle and source transactions; direct process nodes/routes fit existing records; scenario-specific anchors stay explicit | Requires real core/source/API work, and pure geometry extraction from desktop | **Proposed choice**, with the type partition below |
Reuse direct process `NodePlacement` and existing relation `EdgeRoute` records. Do not distort scenario arrows into fake semantic relations just to use EdgeRoute. Do not force every scenario representation into a generic rectangle with meaningless fields. Add typed scenario presentation keyed by occurrence/lifeline identity and typed qualified-process presentation keyed by full call path. These records remain inside the same Diagram, with the same history/source owner. This is a deliberate domain distinction, not separate persistence technology.
## Proposed persistence contract
This section is an implementation candidate requiring specification review, not a declaration of existing syntax. The current schema/source version is7. Reserve a new version during integration; examples below use8 provisionally. Coordinate the version with other schema owners before editing it.
1. Add `ProjectionKind::Process { process: Id }` accepting a Process or FunctionalChain owner, and `Scenario { scenario: Id }` accepting a Scenario, each in the diagram's layer. A diagram has one explicit owner. Multiple saved views of that owner are legal and independent. Owner identity and diagram identity are distinct.
2. Process direct occurrence boxes use ordinary `Diagram.nodes`: one placement per included occurrence in a view, positioned independently of its represented work/exchange. Explicit SequenceLink and ContextualLink geometry uses ordinary `Diagram.routes`. Moving any process node changes only its placement; it does not edit links, parent, steps, represented element, or allocation. Automatic rank is only a seed/explicit Arrange result.
3. A typed optional process presentation block holds `context_nodes` and `context_routes`. A contextual node key is `OccurrenceEndpoint { references: Vec<Id>, occurrence: Id }`. A contextual route key is `{ references: Vec<Id>, edge: Id }`, where the edge belongs to the resolved called process. Direct ContextualLink edges still use their own relation ID in normal routes. Each key is unique per view. Retain full IDs in the scene, including preview links; never use labels, tile indices or abbreviated paths as keys.
4. A typed optional scenario presentation block has separate records for lifeline headers (`lifeline`, `x`, `width`, `height`), message anchors (`occurrence`, desired `y`), boxes/frames (`occurrence`, desired rectangle), operand presentation extents, and message routes (`occurrence`, pinned waypoints, optional EdgeLabel). Types reject inapplicable fields. Lifeline x is presentation order: the present metamodel does not contain Sirius's explicit semantic instance-role ordering, so do not invent one. Normalized route-label fractions/offsets and finite coordinate/size limits reuse existing primitives.
5. Scenario box/frame time anchors have one canonical derivation from their rectangle (specified per kind, e.g. top event/header anchor); no competing stored `y` values for one occurrence. Birth/death ends and execution intervals remain derived semantic attachments. Header x/size is authored; creation timing determines its vertical attachment. A box attached to a lifeline may have a presentation x offset/leader while retaining the same lifeline reference. A cosmetic horizontal move must never rebind its participant.
6. Persisted process child coordinates are absolute, matching normal nodes. Moving a reused group translates its visible descendant presentation records in one batch if group movement is selected; resizing a containing frame preserves child authored coordinates, consistent with #16. Scenario frame resizing changes frame geometry and available space; it does not silently add/remove covered events, change operands or move semantic ownership.
7. Valid, feasible authored geometry is used exactly. Missing records use deterministic initial geometry. Passive rendering/navigation never creates records, marks dirty, saves, or resets authored geometry. Existing records survive collapse, temporary invisibility, filters and owner renames. Delete/reidentify/reparent/view-owner changes must explicitly clean or transform dependent references in the core transaction; stale path records cannot silently bind to a new same-named object.
8. New records count against parse/model limits; route points retain a bounded per-route limit. Resolve caller paths with existing cycle/depth defenses and a bounded visible expansion budget. An explicit partial expansion is not permission to discard invisible authored records. Large stored layouts should not be rebuilt in App.update per pointer move.
Example candidate source, following existing constructor/record conventions:
```text
arcadia 8;
// Existing semantic declarations omitted for brevity.
diagram response_view "Response workflow" {
layer Oa;
projection { Process { process @oa_response; }; };
routes [{ edge @oa_confirm_path;
waypoints [{ x 580; y 110; }, { x 580; y 360; }];
label { fraction 0.5; offset { x 0; y -16; }; }; }];
process_layout {
context_nodes [{ endpoint { references [@oa_assess_step];
occurrence @oa_assessment_action; };
position { x 95; y 185; };
size { width 210; height 85; }; }];
};
}
node confirm_view {
diagram @response_view; element @oa_confirm_step;
position { x 710; y 350; }; size { width 320; height 125; };
}
diagram conversation_view "Conversation" {
layer Oa;
projection { Scenario { scenario @oa_conversation; }; };
scenario_layout {
lifelines [{ lifeline @oa_caller; x 180; width 240; height 90; },
{ lifeline @oa_responder; x 660; width 280; height 90; }];
messages [{ occurrence @oa_call; y 210; },
{ occurrence @oa_reply; y 390; }];
message_routes [{ occurrence @oa_call;
waypoints [{ x 430; y 240; }];
label { fraction 0.55; offset { x 0; y -20; }; }; }];
};
}
```
`oa_call` and `oa_reply` intentionally have independent anchor/route records although the release fixture uses the same underlying `oa_contact` exchange for both. `oa_confirm_path` is the fixture's explicit link from `oa_merge` to `oa_confirm_step`. Exact grammar, diagnostics and canonical formatting must be specified and round-tripped before UI depends on this candidate.
API candidate: existing CreateDiagram/CreateNode/MoveNode/EditNode/SetEdgeRoute remain ordinary direct-process operations. Add typed upsert/remove operations for contextual nodes/routes and scenario lifelines/items/message routes, or one `EditBehaviorLayout { diagram, patch }` with a strictly tagged list of those operations. Prefer bounded per-record patches for scripting and safe comment retention; retain full EditDiagram replacement for existing generic clients. Do not make full-diagram replacement the only geometry API. A scenario message move changes its anchor and any explicitly previewed affected neighbor anchors in one `Batch`; an explicit reorder adds `SetSteps` to that same batch. REST uses the existing conditional transaction route and concurrency rules. Source writeback updates only the owning diagram declaration/file and matches nested comments by full typed key.
## Scenario drag policy: compare before deciding
The user requested movable arrows, not implicit behavioral mutation. In the current model, ordered occurrences and operand membership carry sequence meaning independently of their visual y. There are two materially different policies:
| Policy | Benefit | Risk | Assessment |
| --- | --- | --- | --- |
| Ordinary drag crossing another event commits `SetSteps` | Closest to Sirius event reorder; quick behavioral edits | An engineer making room for a long label can change execution; a few pixels of overshoot is consequential; source diff mixes geometry with semantics | Do not make this the default without direct user-workflow evidence |
| Ordinary drag edits presentation; explicit Reorder changes steps | Cosmetic operations preserve semantics; source diff/Undo intent is clear | Need an understandable constraint/ripple preview and an accessible reorder command | **Recommended default** |
Within the second policy, a hard tiny-gap clamp would remain frustrating and could repeat the rigid-canvas failure. Prefer a local spacing planner: drag within available space moves the chosen anchor; dragging farther proposes moving the affected following/preceding event block while retaining semantic order. Show every moved row in the preview. The user can insert/remove vertical space directly, spread lifelines freely, move labels/bends independently, and resize boxes/frames. A selected arrow itself is the handle for movement; no left-margin rectangle is required. A pointer cursor/status can say “Move message” or “Reorder message” according to the active action. Do not silently fall back from a blocked layout move to semantic reorder.
Provide named Reorder in the common context menu/Properties actions and keyboard accessibility. An optional modifier may invoke the same action after it is discoverable; the modifier is not the sole route. Reorder shows a before/after insertion marker in the actual operand and previews lifecycle/activation changes. Cross-operand movement remains a separately named semantic action until its valid rules are implemented. Complete reorder in one atomic semantic+layout transaction, not a SetSteps commit followed by a best-effort geometry save.
Two storage choices for constrained y require an early evaluator:
- Absolute desired anchors are easy to understand in text and support precise coordinates. When source editing changes steps without adjusting layout, the pure projector must resolve only actual order/containment conflicts, retain authored values and report displaced anchors; it must not allow a contradictory rendered sequence or rewrite source during render. GUI reorder should persist its previewed resolved anchors, so reopen matches exactly.
- Nonnegative gap-before records naturally survive order edits and make blank-space insertion simple. They are less direct for exact coordinates; box-height changes move downstream events by construction, and frame/nested-call constraints still need a solver. They also risk recreating an automatic-only layout if exposed as the only UI freedom.
Recommend absolute desired anchors plus a deterministic, explicitly tested local conflict resolver, subject to falsification on source reorder/nesting fixtures before commitment. A conflicting source layout is a presentation diagnostic, not grounds to reject a semantically valid model or silently mutate its order. The renderer must distinguish stored geometry from resolved geometry. No broad layout run occurs on selection, property inspection, camera changes, label edits that still fit, or switching views. If the resolver cannot meet hard semantic anchors, it produces a clear bounded conflict result; it does not claim that an unsearched resize/move is impossible.
Connection bends are presentation-only. Process edges attach to their existing source/target occurrences; route handles move/add/remove pinned points and labels use existing fraction+offset behavior. Scenario message ends remain attached to the existing sender/receiver and event anchor; interior bendpoints/label offsets do not encode send/receive order. Self-message loops must be resizable/routable while preserving their occurrence identity. Reconnection is a different visible handle/action and uses semantic validation. Derived legacy process adjacency lacks a stable relation ID: either give its presentation a typed `{source_occurrence,target_occurrence}` key with lifecycle rules, or require an explicit link before authoring its route. Never create a hidden SequenceLink as a side effect of dragging an elbow. This is a concrete remaining route-coverage decision for legacy fixtures.
## Interaction and architecture boundaries
Extract pure process/scenario geometry projection into oa-core (or an existing UI-free shared crate if the core layering demands it). Inputs are project, explicit view identity/presentation, and bounded expansion state. Outputs have stable typed representation targets, actual hit bounds, attachment anchors, routed polylines, constraints and diagnostics. Fonts/text measurements need a deterministic shared interface or conservative documented metrics; do not put Iced types into persisted data. Keep initial layout and explicit Arrange as planners separate from projection. The same candidate projector must drive drag preview, committed render, navigation bounds, export and geometry tests.
Reuse architecture route math/label projection and peer resize planning rather than copying its algorithms into both behavior renderers. The existing [`routing::route_diagram`][routing] is coupled to projected diagrams, so first reuse primitives or adapt typed scene edges; do not pretend it already accepts sequence events. Process graph topology/rank logic remains valuable for initial/selected Arrange. Scenario lifetime/call semantics remain a separate input to geometry.
Interaction state is transient: `Gesture { document, diagram, owner, revision, epoch, target, original, candidate }`. Pointer-down resolves one exact rendered target, pointer moves only update a candidate, and release revalidates the complete scope before one normal transaction. Escape, focus loss, document/view switch, modal entry, source changes and stale revisions cancel without history. Zero movement retains exact authored data. Camera changes must either transform the captured gesture consistently or cancel it; they cannot replay a saved release against a new view. Peer resize hit arbitration must order visible resize/route handles ahead of overlapping invisible port slop.
Canvas is always present and pointer-active whenever its document is editable. Remove the user-facing Canvas activation button and mode-dependent hiding of editing essentials. F6 may remain ordinary focus traversal; text inputs still own text/navigation keys, and dialogs/drafts retain their existing guards. “Always active” must not make Delete/arrow keys operate behind a focused Properties text field. Keep the stamped control/input protection already introduced in the camera prerequisite.
Use one conventional selection and Properties workflow: click a box/arrow selects it, selected objects display familiar handles, double-click/name action edits its name, context menu offers relevant semantic actions, and the common Properties panel follows selection automatically. Replace the Details shelf with this panel; avoid a second selection editor or tiny reorder controls. Tools remain available in a compact palette. At the minimum supported window, the panel must be collapsible/resizable or use a stable shared inspector region; selection must not shift the canvas coordinate origin mid-gesture. The existing300px opaque overlay is evidence of current behavior, not proof that it is an acceptable final layout. Root needs native evaluation for hit size, palette contrast, long names and keyboard flow.
Saved-view identity must propagate to `Scope`, process/scenario State identity, camera cache, recovery target and diagram-usage navigation. Current process/scenario usages identify owner-based surfaces; multiple views require the saved Diagram ID as well. Qualified occurrences retain their full paths. Navigation selects/reveals the actual saved appearance without creating a view, resetting geometry or applying drafts. Camera can remain session-local; authored expansion/collapse affecting the saved view must have an explicit persistence policy rather than being conflated with camera focus.
## Migration, source edits and lifecycle
- Old version7 source/JSON loads without manufacturing views or geometry. New optional blocks default absent. Frozen old-version readers must reject new fields/owner constructors rather than silently accept and drop them. Update schema gates, serde/parser defaults, formatter, strict reST, API schemas/OpenAPI, source limits and transition/copy/reference walkers together.
- With no saved behavior view, show the current deterministic initial presentation and allow immediate gestures. The first accepted geometry edit creates the saved view and required initial presentation in the same Undo transaction. This is an internal lifecycle, not a new Canvas-enable prompt. View creation must allocate IDs once before preview/source preparation; no render-time random IDs. Merely opening a scenario remains clean.
- Decide whether initial capture is complete or sparse before implementation. Complete capture of the currently rendered direct surface gives stronger protection against unrelated future edits moving objects; sparse overrides create smaller source but leave untouched objects dependent on changing automatic ranks. Recommend complete direct-surface capture on the first layout edit, with unseen qualified expansions initialized lazily. Preserve omitted/invisible semantic objects as omissions; do not turn opening a view into a semantic model synthesis.
- Changing semantic names preserves positions/routes and grows/clips only according to an explicit content-size policy. Show long names through wrapping, handles and Properties; automatic size growth cannot silently overwrite stored dimensions. New objects get a local collision-aware initial position and do not trigger Arrange All.
- Deleting a view removes only presentation; deleting an occurrence removes its direct/qualified layout and route keys; deleting a caller invalidates paths through it; reidentification transforms every reference while preserving geometry/comments. Changing an owner or reparenting must reject or explicitly repair incompatible presentation in the same transaction. Coordinate these dependency walkers with peer Delete ownership before touching them.
- Source reorder and semantic relation edits must preserve all nonconflicting authored geometry. Layout diagnostics/derived conflict resolution must be deterministic and available outside the GUI. Text-only users can fully author the same representation and reset individual routes/placements without opening a window.
- Saving/reopening, REST import/export, copy/transition and undo/redo must preserve distinct repeated occurrences, qualified paths, pinned route points and source comments. A route whose edge is temporarily hidden remains stored, matching existing architecture semantics.
## Milestones and acceptance that can falsify this proposal
1. **Contract/core slice first.** Choose schema ownership and event-anchor policy; implement one owner-bound process view with ordinary nodes/routes and one two-lifeline scenario view with separately keyed call/reply anchors/routes. Prove parse/format/model equality, JSON/source migration, REST conditional transaction and one-Undo source parity before expanding gestures. Independent tests must use expected coordinates/semantic invariants, not merely call the same planner twice.
2. **Shared geometry/resize integration.** Extract scene projection and connect the #16 candidate planner. Exercise eight handles, active/inactive axes, port/state counterexamples where shared code applies, collapsed reuse and nested frame bounds. Demand projected preview=commit=reopen, cancellation=no change, child authored coordinates unchanged on resize. Failed bounded search is not proof of a minimum.
3. **Direct manipulation and Properties.** On the actual minimum window and realistic large model, enter a process/scenario and immediately drag the object itself, resize from each handle, edit a bend/label, select and change a property. No activation button, Details detour, unexplained rectangle or moving viewport origin. Test text focus, Tab/F6/Escape, touchpad/mouse camera, modal entry mid-drag and dirty-source refusal/recovery.
4. **Behavior-specific depth.** Process cycles/parallel links, repeated callee appearances and expanded nested callers; scenario same-exchange call/reply, self-message, long lifeline labels, create/delete, ALT/PAR/LOOP operands, ScenarioUse and nested activation #21. Verify routing/labels and bounds after every move. Finish all visible object kinds rather than calling the simple fixture feature-complete.
5. **Preservation/regression/package.** Source comments across included files, two views per owner, hidden routes, reidentify/delete, conditional REST stale revisions, save/reopen, undo/redo, source reorder conflict diagnostics, navigation to the exact saved appearance, and long-model responsiveness. Root must run native workflow review and fresh independent critique, then integrate/publish a versioned milestone with honest remaining issues.
Concrete user-workflow checks:
- In `scenario_oa_conversation.arcadia`, widen/spread caller/responder until the full call label is readable, move call/reply to separately chosen y positions, edit only the call route, save and reopen. Underlying exchange IDs, `reply_to`, sender/receiver, steps and lifetime semantics are byte-for-byte unchanged after cosmetic operations. The two appearances retain different geometry.
- Drag the call down across the reply's initial screen row. Default movement preserves semantic order and shows any spacing/ripple adjustment. Invoke Reorder explicitly and verify a valid/invalid preview according to call/reply semantics; there is no automatic `SetSteps` hidden in ordinary drag.
- In `process_oa_response.arcadia`, put the confirmation box below/left of its previous rank column, resize it, bend its explicit incoming edge around another object and move its label. Links/steps/represented work do not change. Reopening does not return the box to a rank column.
- Place two references to the assessment procedure, expand each, position/resize one visible inner occurrence and route an inner link where present. Only that caller path changes; opening the callee definition and the other caller is unaffected. Collapse/reexpand retains it.
- Resize a fragment around existing operands, then resize a reuse card: child authored coordinates and semantic membership stay unchanged. Use a separately named semantic action to change membership. New nested activation support must use the same geometry contract rather than regenerate all rows.
- Edit names/steps in source with comments and included files; undo restores both source and geometry. A source reorder that conflicts with authored y produces a deterministic, inspectable presentation result without rejecting a valid model, silently saving, or editing semantics from pixels.
## Remaining decisions and limits
The proposed absolute-anchor conflict resolver, local ripple behavior, legacy derived-path route identity, saved expansion policy, and exact Properties layout need early independent evaluators/native evidence. They are explicit design questions, not authorization to defer basic persistence or free manipulation. Capella's behavior is a comparison, not the required product specification.
Official primary references support the distinction: [Sirius sequence user documentation](https://eclipse.dev/sirius/doc/user/sequences/Sequence%20Diagrams.html) describes movable lifelines, stored header height, manual blank-space insertion and order-preserving layout. Its [sequence specifier documentation](https://eclipse.dev/sirius/doc/specifier/sequences/Sequence%20Diagrams.html) explicitly delegates event/instance reordering to semantic tools when graphical movement changes order. [General Sirius diagram documentation](https://eclipse.dev/sirius/doc/user/diagrams/Diagrams.html) supplies ordinary shape movement/resizing, route bendpoint and label interactions. These are useful interaction precedents; they do not prove native Capella's current configuration or justify copying its menu density. [Capella's official feature overview](https://mbse-capella.org/features.html) distinguishes functional, exchange and interface scenarios; the same presentation architecture should apply across the application's supported scenario viewpoints rather than fixing only one.
[model]: ../../../crates/oa-core/src/model.rs#L1431
[topology-state]: ../../../crates/oa-desktop/src/behavior/topology.rs#L79
[graph]: ../../../crates/oa-desktop/src/behavior/topology.rs#L1000
[process-input]: ../../../crates/oa-desktop/src/behavior/topology/direct.rs#L662
[process-handle]: ../../../crates/oa-desktop/src/behavior/topology/direct.rs#L562
[context-preview]: ../../../crates/oa-desktop/src/behavior/topology/contextual.rs#L253
[scene]: ../../../crates/oa-desktop/src/behavior.rs#L1228
[scene-occurrences]: ../../../crates/oa-desktop/src/behavior.rs#L1393
[presentation]: ../../../crates/oa-desktop/src/behavior/scenario/workspace.rs#L14
[scenario-handle]: ../../../crates/oa-desktop/src/behavior.rs#L1819
[scenario-reorder]: ../../../crates/oa-desktop/src/behavior/scenario.rs#L1071
[lifecycle]: ../../../crates/oa-desktop/src/behavior/scenario_lifecycle.rs#L70
[controls]: ../../../crates/oa-desktop/src/behavior/canvas_workspace.rs#L13
[host]: ../../../crates/oa-desktop/src/behavior/canvas_workspace.rs#L214
[language-diagrams]: ../../../docs/language.rst#L1056
[commands]: ../../../crates/oa-core/src/session.rs#L11
[document]: ../../../crates/oa-desktop/src/document.rs#L315
[server]: ../../../crates/oa-server/src/routes.rs#L1017
[routing]: ../../../crates/oa-core/src/routing.rs#L429
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
# Issue 21 — independent activation rendering analysis
2026-09-12, `/root/diagram_usage_workflows`, for root synthesis and peer Codex-956atf's implementation ownership. Read current issues/comments #20 and #21, including peer claim637 and root claim641. Root subsequently yielded implementation to peer956 and assigned this independent read-only evaluation. No production writes, compiler, model experiment, or native application interaction were performed. Active inspected worktree: `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/diagram-usages-ytr83r-20260912`, base7aa007b plus root's diagram-usage working changes. Source line references identify the inspected files and may shift after integration.
## User requirement and scope
[Issue21](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21) gives the exact stored order Call1 L1→L2, Call2 L1→L2, Reply2 L2→L1, Reply1 L2→L1 and reports invisible inner call blocks. Comment622 asks to examine Capella because the user likes its treatment. The attached image at root `.local/gitea-issue21-25269f9e.png` shows two calls arriving at the left lifeline and one visible pale strip with no separate nested activation apparent. This supports the reported visual symptom but does not reveal the complete model, both correlations, build version, or screenshot provenance beyond the issue attachment.
[Issue20](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/20) requires less rigid scenario diagrams: ordinary direct controls for spacing lifelines, moving arrows, resizing elements and avoiding clipped names, comparable in ease to architecture editing. The #21 correction should not hardwire a new geometry scheme that makes that later work more difficult. This analysis does not propose taking #20 implementation scope.
## Findings: verified source facts
### 1. Overlapping activations have identical horizontal geometry and UUID-dependent paint/hit precedence
`behavior.rs:1339–1363` gathers Calls by `scene.rows.keys()` from a BTreeMap keyed by occurrence ID, then invokes `activation_sequence` for each Call. This is UUID order, not call occurrence order or outer-before-inner order.
`activation_sequence` follows the particular Call's `reply_to` correlation and correctly can produce two different vertical spans for the reported simple case. However, every `activation_segment` (`:1657–1678`) receives exactly the receiver lane x and emits the same `x−4` origin and width8. Reply caps (`:1739–1751`) likewise use fixed `x−7`,width14. There is no nesting depth, horizontal interval assignment or activation-specific anchor.
For y1<y2<y3<y4, both Call1's span [y1,y4] and Call2's span [y2,y3] occupy [x−4,x+4]. `Scene::draw` (`:2169` onward) paints filled boxes in their stored order before messages. If the outer Call sorts later, its opaque fill/stroke covers the whole inner interval. If the inner sorts later, some short cross-lines can survive, but there is still no distinct offset nested block. This is sufficient source evidence for the reported symptom; it does not depend on a guessed GPU bug.
`Scene::hit` (`:1848–1858`) searches empty-title boxes in reverse order after message/header checks. Thus the same overlap makes a click select whichever Call's bar was appended last, regardless of intended nested identity. A paint-only color tweak would leave interaction ambiguity.
### 2. Messages are attached to lane center, not to a distinct activation boundary
Occurrence geometry (`:1505–1531`) derives call/reply polyline endpoints from lane x positions. Birth/death rendering later adjusts lifecycle messages, but ordinary call/reply endpoints remain at the lane center. Offsetting activation rectangles alone would leave the arrowhead or reply origin disconnected from the corresponding block, or passing through the wrong block.
Therefore the correction needs a shared call-activation geometry association for draw, message endpoints and hit testing, retaining the original occurrence/correlation IDs. The semantics do not require a new persistent activation model record merely to render that association.
### 3. Recursive/self-call activation starts at the send row, 24 units above its receive endpoint
The self-message polyline ends at `(x, y+24)` (`:1513–1520`); its occurrence row is recorded at y. `activation_sequence` starts its receiver activation at `self.rows[call].y` (`:1710`) rather than that receive anchor. Thus a self-Call bar currently begins before its drawn receive endpoint. For ordinary horizontal calls both anchors have the same y, which hides this defect.
For self-replies, termination should follow the replying activation's send anchor; the subsequent receive anchor belongs to the resumed caller. A correction that uses the same row as both events would perpetuate the recursive case and conflict with movable arrows in #20. No native reproduction was performed here; the 24-unit mismatch is directly derivable from the code.
### 4. Existing fragment and lifetime behavior is meaningful and must be preserved
`activation_sequence` splits an inherited activation across Fragment operands, identifies a Reply by its exact correlation, stops branch-local segments at matching Reply/Delete, and leaves a visible unresolved cue for open continuations. ALT resumes when a path can remain open; PAR, OPT and LOOP have distinct continuation rules (`:1755–1787`). These are not interchangeable flattened vertical intervals.
`scenario_lifecycle/render.rs:154–174` clips activation spans to the receiver's Alive intervals in the same scope. An uncreated/dead/unresolved lifetime must not gain a visually asserted active span through the correction. Create/Delete markers paint after ordinary bars/messages, with their original occurrence identity.
Existing tests (`behavior.rs:direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand` and `direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call`) check span ends, operand splits and unresolved cues for one Call. They do not test two overlapping same-receiver activations, UUID permutation, endpoint attachment or distinguishable hit regions. They therefore do not disprove the reported defect.
## Semantic boundary: what nesting must and must not imply
`docs/language.rst:678–701` requires Reply to reference a preceding Call on a guaranteed common path, reverse its actual lifelines, and avoid duplicate replies that may follow on one shared execution path. It does not impose a LIFO stack between replies to different Calls. Rendering should not silently change that contract.
The issue's y1<y2<y3<y4 correlations form strictly nested intervals, so an offset inner block is well justified. A crossing case Call1,Call2,Reply1,Reply2 must still preserve both admitted correlations. Treat it as two overlapping active intervals with separate visible tracks, not as a proof that Call2 is semantically invoked by Call1 or a reason to rewrite which Reply completes which Call. Calls from two different senders can overlap on one receiver without their senders being part of one execution stack.
Likewise, different ALT operands are exclusive paths; screen stacking does not make their Calls nested. PAR operands are not a fabricated total execution order. Track assignment should be scoped by branch-path compatibility and inherited open activations, not by raw rectangle containment across the entire flattened canvas.
## Primary comparison evidence, with limits
The official [Sirius sequence-editor specification](https://help.eclipse.org/latest/topic/org.eclipse.sirius.doc/doc/specifier/sequences/Sequence%20Diagrams.html) describes executions with start and finish events, recursive execution containment, and mappings that connect those events to graphical elements. It also separates graphical ordering from semantic ordering and requires them to remain coherent. This supports an explicit event/activation geometry mapping and nested border-node treatment rather than painting multiple indistinguishable rectangles on a lifeline. It does not independently establish how the user's installed Capella version renders this particular model.
Official [Capella features](https://mbse-capella.org/features.html) distinguishes functional, exchange and interface scenarios by their lifeline/message meanings. The same geometric nested-activation correction should therefore be assessed against supported Studio scenario viewpoints rather than assuming every Call is a software operation.
The official [Sirius historical release notes](https://help.eclipse.org/latest/topic/org.eclipse.sirius.doc/doc/Release_Notes_Previous.html) describe direct execution resizing within operands with enclosing containers growing as needed, and sequence-diagram control of geometry to preserve semantic ordering. That is relevant to #20 compatibility: editable geometry can remain constrained by event/ownership semantics. It is not evidence of current native Capella affordances. Peer owner retains the requested comparable native exploration; this report does not duplicate it.
## Independently derived acceptance expectations
Fixture identity/correlation maps should be handwritten separately from the renderer's output. Use exact Call/Reply IDs, lane IDs, scope paths and stored order. Tests must check observable visible geometry and selection, not only count BoxItems or confirm no panic.
| Case | Independent expected behavior |
| --- | --- |
| Exact user case, L1 left of L2 | With order C1,C2,R2,R1 and correlations R2→C2,R1→C1, show outer receiver activation from C1 receive to R1 send; inner activation from C2 receive to R2 send. Inner block has a visible offset boundary, and each Call/Reply endpoint attaches to its own block. A point in each exposed strip selects the corresponding Call. |
| Mirrored user case | Exchange the physical left/right lane positions while retaining the same message/correlation identities. The two spans remain visibly distinct and correctly attached. An implementation must not assume sender is left of receiver. |
| UUID permutation | Reidentify or construct IDs so C1 sorts before C2, then so C1 sorts after C2, while preserving each model's equivalent order/correlations. Geometric layout and which block appears on top must be equivalent modulo identities. Neither paint nor hit precedence may switch arbitrarily with ID order. |
| Three nested Calls to same receiver | C1,C2,C3,R3,R2,R1 produces three visible, separately selectable intervals, with each inner receive/send attachment at its own depth. Ending C3 does not end C2/C1. |
| Nonoverlapping repeat | C1,R1,C2,R2 reuses the base track when no interval overlaps. Do not accumulate horizontal indentation permanently by chronological Call count. |
| Crossing overlap | C1,C2,R1,R2 retains each explicit correlation and shows both active intervals without hiding one. Do not change semantic order or fabricate LIFO rejection. If the core rejects a particular fixture for an independent channel/viewpoint reason, repair that fixture rather than interpreting the unrelated rejection as a stack rule. |
| Opposite-direction reentrancy | C1 L1→L2, C2 L2→L1, C3 L1→L2, then R3,R2,R1. On L2, C3 overlaps C1 and needs a distinguishable nested/overlap track; C2 belongs to L1. Per-lane identity and reply association remain exact. |
| Recursive self-call | C1 L→L, C2 L→L, R2 L→L, R1 L→L. Every activation begins at its Call's actual receive endpoint (currently y+24 for the elbow), not its send row; each terminates at its correlated Reply's send endpoint. Elbows clear all live activation tracks and their arrowheads land on the correct boundaries. |
| ALT with inherited outer Call | Outer C1 precedes ALT. Operand A contains C2,R2,R1; operand B contains its own C3,R3 and leaves C1 open. Distinct inner tracks coexist with branch-local C1 segments. No C1 segment joins across operand headers; no activation depth leaks from A into B. Only B/possible continuation remains unresolved. |
| PAR and optional/repeated fragments | Test independent Calls in separate PAR operands and an inherited outer Call under OPT/LOOP. Preserve current valid/invalid correlation rules and uncertainty cues. Different screen operand rows do not manufacture nesting or serialize concurrent paths. An optional/repeated Reply cannot assert guaranteed completion. |
| Delayed creation/deletion | Create L2, then C1,C2,R2,R1,Delete L2: no activation before birth/after death; both nested intervals remain visible while alive. Separate warning-admitted unresolved-lifetime case retains neutral gaps. If Delete occurs while a Call remains open and is admitted, it clips the visible activation at death and preserves unresolved-call indication, never a false Reply cap. |
| Unreplied inner/outer | C1,C2 with missing R2 and/or R1 retains separate identifiable incomplete indications where current semantics permit. Do not use a guessed end to merge or hide the two calls. |
| Viewpoint/layer and typography | Exercise at least ordinary OA exchange scenario and SA boundary/interface Call with actual operation identity. Same geometry invariant, no alias/name correlation guessing. Repeat with long and Unicode labels, 100%,125%,pan and minimum supported window; label backgrounds, markers and selection do not erase the exposed block boundary. |
| Passive and edited layout | Query/render/select/pan/zoom preserves complete Project, accepted source envelope, source buffers and undo/redo history. In the future #20 layout representation, move lane coordinates or message geometry while retaining correlation/order; the same event-based activation association follows the chosen anchors and survives save/reopen without adding semantic edges or silently reordering steps. |
Geometric oracle for the minimal issue: let receive/send anchors be (xC1,y1),(xC2,y2),(xR2,y3),(xR1,y4) with y1<y2<y3<y4. Verify C1 spans [y1,y4], C2 spans [y2,y3], the bars are not identical in x range, both expose a drawable and selectable boundary during overlap, and R2 remains associated with C2. No required absolute pixel width/offset is imposed here; the implementation must demonstrate legibility at the actual tested scale. A screenshot alone is insufficient to prove identity selection; a hit geometry assertion alone is insufficient to establish visible contrast.
## Bounded correction candidates and tradeoffs
| Candidate | Benefits | Falsification / limitation | Assessment |
| --- | --- | --- | --- |
| Sort existing boxes and recolor selected inner bar | Smallest local edit | Same x range remains; one bar occludes another, hit ambiguity persists, recursive anchor still wrong | Insufficient. Useful only as part of a geometric correction. |
| Add derived ActivationSegment metadata, then assign deterministic receiver-local visible tracks and shared message anchors | Reuses existing correlation/branch/lifetime traversal; can separate segment derivation from display geometry and keep source untouched; supports exposed hits and endpoints | Must carry scope/path and start/end event identity, handle crossing intervals and inherited branches, and prove opposite direction/self-call; naive global y nesting would be wrong | Leading bounded correction. Preserve current scope-aware clipping and add one authoritative geometry product consumed by paint/hit/routing. |
| Replace current activation traversal with a full execution-tree/stack semantic model | May support richer execution semantics later | More than user requested; a stack can reject permitted crossing Calls; new persistent execution identities complicate textual/API parity and #20 layout | Defer unless actual requirements establish new semantic executions. Rendering nested activations does not itself authorize a metamodel change. |
| Persistent manual activation rectangles | Can be visually adjusted | Duplicates derivable lifetime/correlation data, invites stale ends after Reply edits, and shifts the original defect to users | Reject as correction; #20 can persist presentation offsets/anchors without making manual rectangles semantic authority. |
Suggested bounded implementation shape: retain existing activation_sequence's branch/correlation traversal, but emit typed records `(call, receiver, scope/path, start/end event anchors, termination state)` before reducing to rectangles. Derive track assignment from comparable active intervals, with stable stored-event order as the primary ordering. Resolve lane positions and message send/receive anchors in a layout pass, then use the same rectangles/anchors for paint, hit targets and navigation bounds. Lifeline birth/death clipping remains explicit. This is an architectural candidate, not reviewed implementation or a mandatory API.
For #20 compatibility, treat `lane_x`, event rows and actual message anchor positions as inputs to geometry rather than deriving activations from hardcoded row offsets forever. Persistent layout fields (when specified by #20) remain presentation data and must not replace `reply_to` or sequence/branch ownership. Do not compute semantic nesting from whichever y coordinates the user dragged on screen.
## Verification status and handoff
The fixed-width overlap, UUID-driven precedence, lane-center attachment and self-call 24-unit start mismatch are verified source facts. The user's image is consistent with them. No runtime reproduction, prototype, correction, automated test pass, native Capella comparison or release acceptance is claimed. Peer956 owns implementation; root should consolidate this evidence with the peer's reproduction and compare its final geometry/hit changes against the independent matrix before integration. Existing single-call/ALT/lifecycle evidence can be reused for unchanged invariants, but same-lane nesting, recursive anchors and UUID independence need new checks.
@@ -0,0 +1,15 @@
# Bounded a57 camera and minimum-window native checks
Root exercised source `a57c334b302eb13352b4bb65df1c75d94d002348`, unoptimized GUI SHA256 `69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40`, in a fresh isolated session on 2026-09-12. Launch and close bind PID 5004 and unchanged GUI/runtime hashes. This is the pre-grapheme a57 executable: no clusters, native combined-source, all-zoom, package or full-product acceptance follows.
The archiver independently viewed all 18 original JPEGs and checked the closed receipts; no new UI, oracle, compiler or application action was performed. Image 01 shows the wide source project opened. Images 02–06 show OA wide-W lifecycle at 100%, Create selected as `life_oa_110`, zoom 125%, Delete selected as `life_oa_112`, and Fit restoring 100%. Header text and Create text remain within the visible card/capsule or viewport; at 125% the headers are partly above the viewport, with no visible spill into the toolbar.
Images 07–08 document native resizing from 1442×932 to 1102×932 and then 1102×732 captured frame; root reports the final client as 1100×700. At 100% the narrow viewport cuts the right side of the receiver card/capsule at its boundary. Images 09–12 show Fit 66%, OA Create selection, content shifted 60 pixels left after root's Right pan, and Delete selected at its new position with alias `life_oa_112`. The left header is then partly outside the viewport and clipped there. Images 13–17 show the SA CJK receiver: switching layer restores 100%; Fit gives 66%; Create selects `life_sa_110`; content moves 60 pixels up after root's Down pan, leaving headers above the viewport; selecting the moved Delete shows `life_sa_112`. No visible header bleed into the toolbar is present. Exact keyboard inputs and camera values (-60,0)/(0,-60) are root's action account, while the images independently support the resulting displacement, zoom labels and selection identities.
At 66% labels are visibly small. These screenshots do not establish general readability, accessibility, focus/keyboard completeness, every clipping edge or every zoom. The visible 19 model checks are not zero diagnostics; the fixture intentionally contains sparse lifecycle cases. No model editing is inferred from selection or camera navigation.
Image 18 records **All source files saved**. The closed lifecycle `same` invocation compares the original `baseline-wide/project.arcadia` with `checkpoints/wide-passive/project.arcadia`, exiting 0 at 2026-09-12T11:02:28.993557Z with `PASS same: all identities, fields, reachable source and physical bytes`. A fresh archival all-file comparison also confirms both directories contain exactly the same two files, 20,214 bytes total. The checker source, historical compilation/self-check/admission and binary/input provenance are included; no fresh checker execution or compilation is claimed by this archive.
Root reports two temporary 1×16 captures during Windows sizing, recovered by fresh window selection and full capture before coordinate input. Those tiny captures are not among the 18 saved JPEGs and are not independently reconstructed here. Root then reports one Alt+F4 and fresh inventory without Studio. The waiting logger independently records exit 0 at 2026-09-12T11:02:56.0410831Z; closed stdout/stderr are empty. Launch's pending acceptance text is historical. This fresh successful bounded run is separate from the [earlier camera picker failure and close](../canvas-native-close-a57c334-20260911/README.md).
`manifest.json` binds exact original/copy bytes. `readback.json` independently rereads original payloads, archived payloads and final metadata inventory. Preparation and provenance may mention clusters, other oracles, ZIPs, working files or prior gates; these are historical references, not acceptance of those cases. Executables, DLLs, ZIPs, profiles and live working directories are excluded. Source-only archival/preparation/launch scripts are records, not instructions to rerun. Root owns integration and publication.
@@ -0,0 +1,29 @@
$ErrorActionPreference = 'Stop'
$originRoot = 'C:\Users\User\Documents\Git\ARCADIA-Studio\.local\native-a57c334-camera'
$destinationRoot = $PSScriptRoot
$paths = @('launch-script-syntax.json','launch.json','normal-close.json','preparation-manifest.json','prepare-inputs.py','second-readback.json','wide-passive-status.json')
foreach ($directory in @('baseline-wide','checkpoints/wide-passive','logs','provenance','screenshots')) {
$paths += Get-ChildItem -LiteralPath (Join-Path $originRoot $directory) -Recurse -File | ForEach-Object { $_.FullName.Substring($originRoot.Length + 1).Replace('\','/') }
}
$entries = @()
foreach ($relative in ($paths | Sort-Object)) {
$original = Join-Path $originRoot $relative
$copy = Join-Path $destinationRoot $relative
if (Test-Path -LiteralPath $copy) { throw "Refusing to overwrite $copy" }
$before = (Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash
New-Item -ItemType Directory -Path (Split-Path $copy) -Force | Out-Null
Copy-Item -LiteralPath $original -Destination $copy
$after = (Get-FileHash -LiteralPath $copy -Algorithm SHA256).Hash
if ($before -ne $after) { throw "Copy mismatch $relative" }
$entries += [ordered]@{path=$relative;original=$original;bytes=(Get-Item -LiteralPath $copy).Length;original_sha256=$before;copy_sha256=$after}
}
$original = 'C:\Users\User\Documents\Git\ARCADIA-Studio\.local\launch-native-a57c334-camera.ps1'
$copy = Join-Path $destinationRoot 'launch-native-a57c334-camera.ps1'
if (Test-Path -LiteralPath $copy) { throw "Refusing to overwrite $copy" }
$before = (Get-FileHash -LiteralPath $original).Hash
Copy-Item -LiteralPath $original -Destination $copy
$after = (Get-FileHash -LiteralPath $copy).Hash
if ($before -ne $after) { throw 'Launch script mismatch' }
$entries += [ordered]@{path='launch-native-a57c334-camera.ps1';original=$original;bytes=(Get-Item -LiteralPath $copy).Length;original_sha256=$before;copy_sha256=$after}
$manifest = [ordered]@{created_utc=[DateTime]::UtcNow.ToString('o');scope='Closed a57 camera evidence only; copying does not execute gates or UI';payload_count=$entries.Count;payload_bytes=($entries|ForEach-Object { $_.bytes }|Measure-Object -Sum).Sum;files=$entries;excluded=@('bin','oracles executables/DLL','baseline-clusters','working-wide','working-clusters','profile','unrelated-cwd','ZIP archives')}
[IO.File]::WriteAllText((Join-Path $destinationRoot 'manifest.json'),($manifest|ConvertTo-Json -Depth 6)+"`n",[Text.UTF8Encoding]::new($false))
@@ -0,0 +1,692 @@
arcadia 7;
project lifecycle_native "Lifecycle native contract" id "5ceab400-0000-4000-8000-000000000001" {
include "rationale.arcadia";
description "Fixed semantic inputs; lifecycle geometry is derived and never authored.";
oa_activity life_oa_10 "OA Controller work" id "5ceab400-0000-4000-8000-0000000003f2" {
description "";
parent none;
}
oa_activity life_oa_11 "OA Receiver work" id "5ceab400-0000-4000-8000-0000000003f3" {
description "";
parent none;
}
oa_scenario life_oa_100 "OA 01 Root create action delete" id "5ceab400-0000-4000-8000-00000000044c" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_110, @life_oa_111, @life_oa_112];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_101 "OA Controller lane" id "5ceab400-0000-4000-8000-00000000044d" {
description "";
parent @life_oa_100;
represented @life_oa_10;
}
oa_lifeline life_oa_102 "WWWWWWWWWWWWWWWWWW" id "5ceab400-0000-4000-8000-00000000044e" {
description "";
parent @life_oa_100;
represented @life_oa_11;
}
oa_occurrence life_oa_110 "OA Create receiver" id "5ceab400-0000-4000-8000-000000000456" {
body {
Exchange {
exchange @life_oa_service;
message_kind Create;
receiver @life_oa_102;
reply_to none;
sender @life_oa_101;
values [];
};
};
description "";
parent @life_oa_100;
postcondition "";
precondition "";
}
oa_occurrence life_oa_111 "OA Receiver action" id "5ceab400-0000-4000-8000-000000000457" {
body {
Action {
action @life_oa_11;
lifeline @life_oa_102;
};
};
description "";
parent @life_oa_100;
postcondition "";
precondition "";
}
oa_occurrence life_oa_112 "OA Delete receiver" id "5ceab400-0000-4000-8000-000000000458" {
body {
Exchange {
exchange @life_oa_service;
message_kind Delete;
receiver @life_oa_102;
reply_to none;
sender @life_oa_101;
values [];
};
};
description "";
parent @life_oa_100;
postcondition "";
precondition "";
}
oa_scenario life_oa_200 "OA 02 Preexisting empty" id "5ceab400-0000-4000-8000-0000000004b0" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_201 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000004b1" {
description "";
parent @life_oa_200;
represented @life_oa_10;
}
oa_lifeline life_oa_202 "OA Receiver lane" id "5ceab400-0000-4000-8000-0000000004b2" {
description "";
parent @life_oa_200;
represented @life_oa_11;
}
oa_scenario life_oa_300 "OA 03 Create only" id "5ceab400-0000-4000-8000-000000000514" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_310];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_301 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000515" {
description "";
parent @life_oa_300;
represented @life_oa_10;
}
oa_lifeline life_oa_302 "OA Receiver lane" id "5ceab400-0000-4000-8000-000000000516" {
description "";
parent @life_oa_300;
represented @life_oa_11;
}
oa_occurrence life_oa_310 "OA Create only receiver" id "5ceab400-0000-4000-8000-00000000051e" {
body {
Exchange {
exchange @life_oa_service;
message_kind Create;
receiver @life_oa_302;
reply_to none;
sender @life_oa_301;
values [];
};
};
description "";
parent @life_oa_300;
postcondition "";
precondition "";
}
oa_scenario life_oa_400 "OA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000578" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_410, @life_oa_411];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_401 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000579" {
description "";
parent @life_oa_400;
represented @life_oa_10;
}
oa_lifeline life_oa_402 "OA Receiver lane" id "5ceab400-0000-4000-8000-00000000057a" {
description "";
parent @life_oa_400;
represented @life_oa_11;
}
oa_occurrence life_oa_410 "OA Open call" id "5ceab400-0000-4000-8000-000000000582" {
body {
Exchange {
exchange @life_oa_service;
message_kind Call;
receiver @life_oa_402;
reply_to none;
sender @life_oa_401;
values [];
};
};
description "";
parent @life_oa_400;
postcondition "";
precondition "";
}
oa_occurrence life_oa_411 "OA ALT delete or keep" id "5ceab400-0000-4000-8000-000000000583" {
body {
Fragment {
branches [@life_oa_412, @life_oa_413];
operator Alternative;
};
};
description "";
parent @life_oa_400;
postcondition "";
precondition "";
}
oa_occurrence life_oa_412 "OA Left delete operand" id "5ceab400-0000-4000-8000-000000000584" {
body {
Branch {
guard "stop_requested";
occurrences [@life_oa_414];
};
};
description "";
parent @life_oa_411;
postcondition "";
precondition "";
}
oa_occurrence life_oa_413 "OA Right empty operand" id "5ceab400-0000-4000-8000-000000000585" {
body {
Branch {
guard "else";
occurrences [];
};
};
description "";
parent @life_oa_411;
postcondition "";
precondition "";
}
oa_occurrence life_oa_414 "OA Conditional delete receiver" id "5ceab400-0000-4000-8000-000000000586" {
body {
Exchange {
exchange @life_oa_service;
message_kind Delete;
receiver @life_oa_402;
reply_to none;
sender @life_oa_401;
values [];
};
};
description "";
parent @life_oa_412;
postcondition "";
precondition "";
}
oa_scenario life_oa_500 "OA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000005dc" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_510, @life_oa_511];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_501 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000005dd" {
description "";
parent @life_oa_500;
represented @life_oa_10;
}
oa_lifeline life_oa_502 "OA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000005de" {
description "";
parent @life_oa_500;
represented none;
}
oa_occurrence life_oa_510 "OA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e6" {
body {
Exchange {
exchange none;
message_kind Create;
receiver @life_oa_502;
reply_to none;
sender @life_oa_501;
values [];
};
};
description "";
parent @life_oa_500;
postcondition "";
precondition "";
}
oa_occurrence life_oa_511 "OA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e7" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_oa_502;
reply_to none;
sender @life_oa_501;
values [];
};
};
description "";
parent @life_oa_500;
postcondition "";
precondition "";
}
oa_scenario life_oa_600 "OA 06 Self delete" id "5ceab400-0000-4000-8000-000000000640" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_610];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_601 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000641" {
description "";
parent @life_oa_600;
represented @life_oa_10;
}
oa_occurrence life_oa_610 "OA Self delete controller" id "5ceab400-0000-4000-8000-00000000064a" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_oa_601;
reply_to none;
sender @life_oa_601;
values [];
};
};
description "";
parent @life_oa_600;
postcondition "";
precondition "";
}
sa_function life_sa_10 "SA Controller work" id "5ceab400-0000-4000-8000-0000000007da" {
description "";
parent none;
}
sa_function life_sa_11 "SA Receiver work" id "5ceab400-0000-4000-8000-0000000007db" {
description "";
parent none;
}
sa_scenario life_sa_100 "SA 01 Root create action delete" id "5ceab400-0000-4000-8000-000000000834" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_110, @life_sa_111, @life_sa_112];
viewpoint Functional;
}
sa_lifeline life_sa_101 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000835" {
description "";
parent @life_sa_100;
represented @life_sa_10;
}
sa_lifeline life_sa_102 "界界界界界界界界界界界界界界界界界界" id "5ceab400-0000-4000-8000-000000000836" {
description "";
parent @life_sa_100;
represented @life_sa_11;
}
sa_occurrence life_sa_110 "SA Create receiver" id "5ceab400-0000-4000-8000-00000000083e" {
body {
Exchange {
exchange @life_sa_service;
message_kind Create;
receiver @life_sa_102;
reply_to none;
sender @life_sa_101;
values [];
};
};
description "";
parent @life_sa_100;
postcondition "";
precondition "";
}
sa_occurrence life_sa_111 "SA Receiver action" id "5ceab400-0000-4000-8000-00000000083f" {
body {
Action {
action @life_sa_11;
lifeline @life_sa_102;
};
};
description "";
parent @life_sa_100;
postcondition "";
precondition "";
}
sa_occurrence life_sa_112 "SA Delete receiver" id "5ceab400-0000-4000-8000-000000000840" {
body {
Exchange {
exchange @life_sa_service;
message_kind Delete;
receiver @life_sa_102;
reply_to none;
sender @life_sa_101;
values [];
};
};
description "";
parent @life_sa_100;
postcondition "";
precondition "";
}
sa_scenario life_sa_200 "SA 02 Preexisting empty" id "5ceab400-0000-4000-8000-000000000898" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [];
viewpoint Functional;
}
sa_lifeline life_sa_201 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000899" {
description "";
parent @life_sa_200;
represented @life_sa_10;
}
sa_lifeline life_sa_202 "SA Receiver lane" id "5ceab400-0000-4000-8000-00000000089a" {
description "";
parent @life_sa_200;
represented @life_sa_11;
}
sa_scenario life_sa_300 "SA 03 Create only" id "5ceab400-0000-4000-8000-0000000008fc" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_310];
viewpoint Functional;
}
sa_lifeline life_sa_301 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000008fd" {
description "";
parent @life_sa_300;
represented @life_sa_10;
}
sa_lifeline life_sa_302 "SA Receiver lane" id "5ceab400-0000-4000-8000-0000000008fe" {
description "";
parent @life_sa_300;
represented @life_sa_11;
}
sa_occurrence life_sa_310 "SA Create only receiver" id "5ceab400-0000-4000-8000-000000000906" {
body {
Exchange {
exchange @life_sa_service;
message_kind Create;
receiver @life_sa_302;
reply_to none;
sender @life_sa_301;
values [];
};
};
description "";
parent @life_sa_300;
postcondition "";
precondition "";
}
sa_scenario life_sa_400 "SA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000960" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_410, @life_sa_411];
viewpoint Functional;
}
sa_lifeline life_sa_401 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000961" {
description "";
parent @life_sa_400;
represented @life_sa_10;
}
sa_lifeline life_sa_402 "SA Receiver lane" id "5ceab400-0000-4000-8000-000000000962" {
description "";
parent @life_sa_400;
represented @life_sa_11;
}
sa_occurrence life_sa_410 "SA Open call" id "5ceab400-0000-4000-8000-00000000096a" {
body {
Exchange {
exchange @life_sa_service;
message_kind Call;
receiver @life_sa_402;
reply_to none;
sender @life_sa_401;
values [];
};
};
description "";
parent @life_sa_400;
postcondition "";
precondition "";
}
sa_occurrence life_sa_411 "SA ALT delete or keep" id "5ceab400-0000-4000-8000-00000000096b" {
body {
Fragment {
branches [@life_sa_412, @life_sa_413];
operator Alternative;
};
};
description "";
parent @life_sa_400;
postcondition "";
precondition "";
}
sa_occurrence life_sa_412 "SA Left delete operand" id "5ceab400-0000-4000-8000-00000000096c" {
body {
Branch {
guard "stop_requested";
occurrences [@life_sa_414];
};
};
description "";
parent @life_sa_411;
postcondition "";
precondition "";
}
sa_occurrence life_sa_413 "SA Right empty operand" id "5ceab400-0000-4000-8000-00000000096d" {
body {
Branch {
guard "else";
occurrences [];
};
};
description "";
parent @life_sa_411;
postcondition "";
precondition "";
}
sa_occurrence life_sa_414 "SA Conditional delete receiver" id "5ceab400-0000-4000-8000-00000000096e" {
body {
Exchange {
exchange @life_sa_service;
message_kind Delete;
receiver @life_sa_402;
reply_to none;
sender @life_sa_401;
values [];
};
};
description "";
parent @life_sa_412;
postcondition "";
precondition "";
}
sa_scenario life_sa_500 "SA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000009c4" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_510, @life_sa_511];
viewpoint Functional;
}
sa_lifeline life_sa_501 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000009c5" {
description "";
parent @life_sa_500;
represented @life_sa_10;
}
sa_lifeline life_sa_502 "SA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000009c6" {
description "";
parent @life_sa_500;
represented none;
}
sa_occurrence life_sa_510 "SA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000009ce" {
body {
Exchange {
exchange none;
message_kind Create;
receiver @life_sa_502;
reply_to none;
sender @life_sa_501;
values [];
};
};
description "";
parent @life_sa_500;
postcondition "";
precondition "";
}
sa_occurrence life_sa_511 "SA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000009cf" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_sa_502;
reply_to none;
sender @life_sa_501;
values [];
};
};
description "";
parent @life_sa_500;
postcondition "";
precondition "";
}
sa_scenario life_sa_600 "SA 06 Self delete" id "5ceab400-0000-4000-8000-000000000a28" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_610];
viewpoint Functional;
}
sa_lifeline life_sa_601 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000a29" {
description "";
parent @life_sa_600;
represented @life_sa_10;
}
sa_occurrence life_sa_610 "SA Self delete controller" id "5ceab400-0000-4000-8000-000000000a32" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_sa_601;
reply_to none;
sender @life_sa_601;
values [];
};
};
description "";
parent @life_sa_600;
postcondition "";
precondition "";
}
sa_port sa_lifecycle_service_input "SA Lifecycle service input" id "a4a6ecc0-3e12-51a5-b08b-199ad743525c" {
contract {
FunctionInput {
incoming_items [];
};
};
description "";
legacy none;
parent @life_sa_11;
}
sa_port sa_lifecycle_service_output "SA Lifecycle service output" id "b1aa66c4-0e8e-5091-beda-2b8d2005b688" {
contract {
FunctionOutput {
outgoing_items [];
};
};
description "";
legacy none;
parent @life_sa_10;
}
interaction life_oa_service "OA Lifecycle service" id "5ceab400-0000-4000-8000-0000000003f4" {
description "";
items [];
source @life_oa_10;
target @life_oa_11;
}
functional_exchange life_sa_service "SA Lifecycle service" id "5ceab400-0000-4000-8000-0000000007dc" {
description "";
items [];
source @life_sa_10;
source_port @sa_lifecycle_service_output;
target @life_sa_11;
target_port @sa_lifecycle_service_input;
}
}
@@ -0,0 +1,2 @@
// Preserve lifecycle note: 雨 · receiver.
// Geometry never changes these bytes.
@@ -0,0 +1,692 @@
arcadia 7;
project lifecycle_native "Lifecycle native contract" id "5ceab400-0000-4000-8000-000000000001" {
include "rationale.arcadia";
description "Fixed semantic inputs; lifecycle geometry is derived and never authored.";
oa_activity life_oa_10 "OA Controller work" id "5ceab400-0000-4000-8000-0000000003f2" {
description "";
parent none;
}
oa_activity life_oa_11 "OA Receiver work" id "5ceab400-0000-4000-8000-0000000003f3" {
description "";
parent none;
}
oa_scenario life_oa_100 "OA 01 Root create action delete" id "5ceab400-0000-4000-8000-00000000044c" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_110, @life_oa_111, @life_oa_112];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_101 "OA Controller lane" id "5ceab400-0000-4000-8000-00000000044d" {
description "";
parent @life_oa_100;
represented @life_oa_10;
}
oa_lifeline life_oa_102 "WWWWWWWWWWWWWWWWWW" id "5ceab400-0000-4000-8000-00000000044e" {
description "";
parent @life_oa_100;
represented @life_oa_11;
}
oa_occurrence life_oa_110 "OA Create receiver" id "5ceab400-0000-4000-8000-000000000456" {
body {
Exchange {
exchange @life_oa_service;
message_kind Create;
receiver @life_oa_102;
reply_to none;
sender @life_oa_101;
values [];
};
};
description "";
parent @life_oa_100;
postcondition "";
precondition "";
}
oa_occurrence life_oa_111 "OA Receiver action" id "5ceab400-0000-4000-8000-000000000457" {
body {
Action {
action @life_oa_11;
lifeline @life_oa_102;
};
};
description "";
parent @life_oa_100;
postcondition "";
precondition "";
}
oa_occurrence life_oa_112 "OA Delete receiver" id "5ceab400-0000-4000-8000-000000000458" {
body {
Exchange {
exchange @life_oa_service;
message_kind Delete;
receiver @life_oa_102;
reply_to none;
sender @life_oa_101;
values [];
};
};
description "";
parent @life_oa_100;
postcondition "";
precondition "";
}
oa_scenario life_oa_200 "OA 02 Preexisting empty" id "5ceab400-0000-4000-8000-0000000004b0" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_201 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000004b1" {
description "";
parent @life_oa_200;
represented @life_oa_10;
}
oa_lifeline life_oa_202 "OA Receiver lane" id "5ceab400-0000-4000-8000-0000000004b2" {
description "";
parent @life_oa_200;
represented @life_oa_11;
}
oa_scenario life_oa_300 "OA 03 Create only" id "5ceab400-0000-4000-8000-000000000514" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_310];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_301 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000515" {
description "";
parent @life_oa_300;
represented @life_oa_10;
}
oa_lifeline life_oa_302 "OA Receiver lane" id "5ceab400-0000-4000-8000-000000000516" {
description "";
parent @life_oa_300;
represented @life_oa_11;
}
oa_occurrence life_oa_310 "OA Create only receiver" id "5ceab400-0000-4000-8000-00000000051e" {
body {
Exchange {
exchange @life_oa_service;
message_kind Create;
receiver @life_oa_302;
reply_to none;
sender @life_oa_301;
values [];
};
};
description "";
parent @life_oa_300;
postcondition "";
precondition "";
}
oa_scenario life_oa_400 "OA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000578" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_410, @life_oa_411];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_401 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000579" {
description "";
parent @life_oa_400;
represented @life_oa_10;
}
oa_lifeline life_oa_402 "OA Receiver lane" id "5ceab400-0000-4000-8000-00000000057a" {
description "";
parent @life_oa_400;
represented @life_oa_11;
}
oa_occurrence life_oa_410 "OA Open call" id "5ceab400-0000-4000-8000-000000000582" {
body {
Exchange {
exchange @life_oa_service;
message_kind Call;
receiver @life_oa_402;
reply_to none;
sender @life_oa_401;
values [];
};
};
description "";
parent @life_oa_400;
postcondition "";
precondition "";
}
oa_occurrence life_oa_411 "OA ALT delete or keep" id "5ceab400-0000-4000-8000-000000000583" {
body {
Fragment {
branches [@life_oa_412, @life_oa_413];
operator Alternative;
};
};
description "";
parent @life_oa_400;
postcondition "";
precondition "";
}
oa_occurrence life_oa_412 "OA Left delete operand" id "5ceab400-0000-4000-8000-000000000584" {
body {
Branch {
guard "stop_requested";
occurrences [@life_oa_414];
};
};
description "";
parent @life_oa_411;
postcondition "";
precondition "";
}
oa_occurrence life_oa_413 "OA Right empty operand" id "5ceab400-0000-4000-8000-000000000585" {
body {
Branch {
guard "else";
occurrences [];
};
};
description "";
parent @life_oa_411;
postcondition "";
precondition "";
}
oa_occurrence life_oa_414 "OA Conditional delete receiver" id "5ceab400-0000-4000-8000-000000000586" {
body {
Exchange {
exchange @life_oa_service;
message_kind Delete;
receiver @life_oa_402;
reply_to none;
sender @life_oa_401;
values [];
};
};
description "";
parent @life_oa_412;
postcondition "";
precondition "";
}
oa_scenario life_oa_500 "OA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000005dc" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_510, @life_oa_511];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_501 "OA Controller lane" id "5ceab400-0000-4000-8000-0000000005dd" {
description "";
parent @life_oa_500;
represented @life_oa_10;
}
oa_lifeline life_oa_502 "OA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000005de" {
description "";
parent @life_oa_500;
represented none;
}
oa_occurrence life_oa_510 "OA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e6" {
body {
Exchange {
exchange none;
message_kind Create;
receiver @life_oa_502;
reply_to none;
sender @life_oa_501;
values [];
};
};
description "";
parent @life_oa_500;
postcondition "";
precondition "";
}
oa_occurrence life_oa_511 "OA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000005e7" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_oa_502;
reply_to none;
sender @life_oa_501;
values [];
};
};
description "";
parent @life_oa_500;
postcondition "";
precondition "";
}
oa_scenario life_oa_600 "OA 06 Self delete" id "5ceab400-0000-4000-8000-000000000640" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_oa_610];
viewpoint OperationalActivity;
}
oa_lifeline life_oa_601 "OA Controller lane" id "5ceab400-0000-4000-8000-000000000641" {
description "";
parent @life_oa_600;
represented @life_oa_10;
}
oa_occurrence life_oa_610 "OA Self delete controller" id "5ceab400-0000-4000-8000-00000000064a" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_oa_601;
reply_to none;
sender @life_oa_601;
values [];
};
};
description "";
parent @life_oa_600;
postcondition "";
precondition "";
}
sa_function life_sa_10 "SA Controller work" id "5ceab400-0000-4000-8000-0000000007da" {
description "";
parent none;
}
sa_function life_sa_11 "SA Receiver work" id "5ceab400-0000-4000-8000-0000000007db" {
description "";
parent none;
}
sa_scenario life_sa_100 "SA 01 Root create action delete" id "5ceab400-0000-4000-8000-000000000834" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_110, @life_sa_111, @life_sa_112];
viewpoint Functional;
}
sa_lifeline life_sa_101 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000835" {
description "";
parent @life_sa_100;
represented @life_sa_10;
}
sa_lifeline life_sa_102 "界界界界界界界界界界界界界界界界界界" id "5ceab400-0000-4000-8000-000000000836" {
description "";
parent @life_sa_100;
represented @life_sa_11;
}
sa_occurrence life_sa_110 "SA Create receiver" id "5ceab400-0000-4000-8000-00000000083e" {
body {
Exchange {
exchange @life_sa_service;
message_kind Create;
receiver @life_sa_102;
reply_to none;
sender @life_sa_101;
values [];
};
};
description "";
parent @life_sa_100;
postcondition "";
precondition "";
}
sa_occurrence life_sa_111 "SA Receiver action" id "5ceab400-0000-4000-8000-00000000083f" {
body {
Action {
action @life_sa_11;
lifeline @life_sa_102;
};
};
description "";
parent @life_sa_100;
postcondition "";
precondition "";
}
sa_occurrence life_sa_112 "SA Delete receiver" id "5ceab400-0000-4000-8000-000000000840" {
body {
Exchange {
exchange @life_sa_service;
message_kind Delete;
receiver @life_sa_102;
reply_to none;
sender @life_sa_101;
values [];
};
};
description "";
parent @life_sa_100;
postcondition "";
precondition "";
}
sa_scenario life_sa_200 "SA 02 Preexisting empty" id "5ceab400-0000-4000-8000-000000000898" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [];
viewpoint Functional;
}
sa_lifeline life_sa_201 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000899" {
description "";
parent @life_sa_200;
represented @life_sa_10;
}
sa_lifeline life_sa_202 "SA Receiver lane" id "5ceab400-0000-4000-8000-00000000089a" {
description "";
parent @life_sa_200;
represented @life_sa_11;
}
sa_scenario life_sa_300 "SA 03 Create only" id "5ceab400-0000-4000-8000-0000000008fc" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_310];
viewpoint Functional;
}
sa_lifeline life_sa_301 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000008fd" {
description "";
parent @life_sa_300;
represented @life_sa_10;
}
sa_lifeline life_sa_302 "SA Receiver lane" id "5ceab400-0000-4000-8000-0000000008fe" {
description "";
parent @life_sa_300;
represented @life_sa_11;
}
sa_occurrence life_sa_310 "SA Create only receiver" id "5ceab400-0000-4000-8000-000000000906" {
body {
Exchange {
exchange @life_sa_service;
message_kind Create;
receiver @life_sa_302;
reply_to none;
sender @life_sa_301;
values [];
};
};
description "";
parent @life_sa_300;
postcondition "";
precondition "";
}
sa_scenario life_sa_400 "SA 04 Conditional delete" id "5ceab400-0000-4000-8000-000000000960" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_410, @life_sa_411];
viewpoint Functional;
}
sa_lifeline life_sa_401 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000961" {
description "";
parent @life_sa_400;
represented @life_sa_10;
}
sa_lifeline life_sa_402 "SA Receiver lane" id "5ceab400-0000-4000-8000-000000000962" {
description "";
parent @life_sa_400;
represented @life_sa_11;
}
sa_occurrence life_sa_410 "SA Open call" id "5ceab400-0000-4000-8000-00000000096a" {
body {
Exchange {
exchange @life_sa_service;
message_kind Call;
receiver @life_sa_402;
reply_to none;
sender @life_sa_401;
values [];
};
};
description "";
parent @life_sa_400;
postcondition "";
precondition "";
}
sa_occurrence life_sa_411 "SA ALT delete or keep" id "5ceab400-0000-4000-8000-00000000096b" {
body {
Fragment {
branches [@life_sa_412, @life_sa_413];
operator Alternative;
};
};
description "";
parent @life_sa_400;
postcondition "";
precondition "";
}
sa_occurrence life_sa_412 "SA Left delete operand" id "5ceab400-0000-4000-8000-00000000096c" {
body {
Branch {
guard "stop_requested";
occurrences [@life_sa_414];
};
};
description "";
parent @life_sa_411;
postcondition "";
precondition "";
}
sa_occurrence life_sa_413 "SA Right empty operand" id "5ceab400-0000-4000-8000-00000000096d" {
body {
Branch {
guard "else";
occurrences [];
};
};
description "";
parent @life_sa_411;
postcondition "";
precondition "";
}
sa_occurrence life_sa_414 "SA Conditional delete receiver" id "5ceab400-0000-4000-8000-00000000096e" {
body {
Exchange {
exchange @life_sa_service;
message_kind Delete;
receiver @life_sa_402;
reply_to none;
sender @life_sa_401;
values [];
};
};
description "";
parent @life_sa_412;
postcondition "";
precondition "";
}
sa_scenario life_sa_500 "SA 05 Unassigned receiver" id "5ceab400-0000-4000-8000-0000000009c4" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_510, @life_sa_511];
viewpoint Functional;
}
sa_lifeline life_sa_501 "SA Controller lane" id "5ceab400-0000-4000-8000-0000000009c5" {
description "";
parent @life_sa_500;
represented @life_sa_10;
}
sa_lifeline life_sa_502 "SA Unassigned receiver lane" id "5ceab400-0000-4000-8000-0000000009c6" {
description "";
parent @life_sa_500;
represented none;
}
sa_occurrence life_sa_510 "SA Create unassigned receiver" id "5ceab400-0000-4000-8000-0000000009ce" {
body {
Exchange {
exchange none;
message_kind Create;
receiver @life_sa_502;
reply_to none;
sender @life_sa_501;
values [];
};
};
description "";
parent @life_sa_500;
postcondition "";
precondition "";
}
sa_occurrence life_sa_511 "SA Delete unassigned receiver" id "5ceab400-0000-4000-8000-0000000009cf" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_sa_502;
reply_to none;
sender @life_sa_501;
values [];
};
};
description "";
parent @life_sa_500;
postcondition "";
precondition "";
}
sa_scenario life_sa_600 "SA 06 Self delete" id "5ceab400-0000-4000-8000-000000000a28" {
context none;
description "";
parent none;
postcondition "";
precondition "";
steps [@life_sa_610];
viewpoint Functional;
}
sa_lifeline life_sa_601 "SA Controller lane" id "5ceab400-0000-4000-8000-000000000a29" {
description "";
parent @life_sa_600;
represented @life_sa_10;
}
sa_occurrence life_sa_610 "SA Self delete controller" id "5ceab400-0000-4000-8000-000000000a32" {
body {
Exchange {
exchange none;
message_kind Delete;
receiver @life_sa_601;
reply_to none;
sender @life_sa_601;
values [];
};
};
description "";
parent @life_sa_600;
postcondition "";
precondition "";
}
sa_port sa_lifecycle_service_input "SA Lifecycle service input" id "a4a6ecc0-3e12-51a5-b08b-199ad743525c" {
contract {
FunctionInput {
incoming_items [];
};
};
description "";
legacy none;
parent @life_sa_11;
}
sa_port sa_lifecycle_service_output "SA Lifecycle service output" id "b1aa66c4-0e8e-5091-beda-2b8d2005b688" {
contract {
FunctionOutput {
outgoing_items [];
};
};
description "";
legacy none;
parent @life_sa_10;
}
interaction life_oa_service "OA Lifecycle service" id "5ceab400-0000-4000-8000-0000000003f4" {
description "";
items [];
source @life_oa_10;
target @life_oa_11;
}
functional_exchange life_sa_service "SA Lifecycle service" id "5ceab400-0000-4000-8000-0000000007dc" {
description "";
items [];
source @life_sa_10;
source_port @sa_lifecycle_service_output;
target @life_sa_11;
target_port @sa_lifecycle_service_input;
}
}
@@ -0,0 +1,2 @@
// Preserve lifecycle note: 雨 · receiver.
// Geometry never changes these bytes.
@@ -0,0 +1,183 @@
#Requires -Version 7.0
# PREPARED ONLY. Root decides when to invoke. Keep this logger alive until normal GUI close.
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$nativeRoot = Join-Path $PSScriptRoot 'native-a57c334-camera'
$nativeFreeze = 'a57c334b302eb13352b4bb65df1c75d94d002348'
$nativeManifestHash = '293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB'
$nativeInputHash = '47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3'
$nativeGuiHash = '69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40'
$nativeDllHash = 'AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A'
$nativeBin = Join-Path $nativeRoot 'bin'
$nativeProfile = Join-Path $nativeRoot 'profile'
$nativeCwd = Join-Path $nativeRoot 'unrelated-cwd'
$nativePath = 'C:\Windows\System32;C:\Windows;' + $nativeBin
$nativeReceipt = Join-Path $nativeRoot 'launch.json'
$nativeTerminal = Join-Path $nativeRoot 'normal-close.json'
$nativeStdout = Join-Path $nativeRoot 'logs/studio.stdout.log'
$nativeStderr = Join-Path $nativeRoot 'logs/studio.stderr.log'
$nativeUtf8 = [Text.UTF8Encoding]::new($false)
function Read-NativeItem([string]$Path) {
$item = Get-Item -LiteralPath $Path -Force
$ancestor = $item
while ($null -ne $ancestor) {
if (($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Reparse path requires separate review: $($ancestor.FullName)"
}
$ancestor = if ($ancestor -is [IO.FileInfo]) { $ancestor.Directory } else { $ancestor.Parent }
}
return $item
}
function Read-NativeFile([string]$Path) {
$item = Read-NativeItem $Path
if ($item.PSIsContainer) { throw "Expected file: $Path" }
return [ordered]@{ path = $item.FullName; bytes = $item.Length; sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash }
}
function Write-NativeRecord($Record, [IO.FileStream]$Stream) {
$bytes = $nativeUtf8.GetBytes(($Record | ConvertTo-Json -Depth 12) + "`n")
$Stream.Position = 0
$Stream.SetLength(0)
$Stream.Write($bytes, 0, $bytes.Length)
$Stream.Flush($true)
}
function Open-NativeOutput([string]$Path) {
return [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read)
}
# Source provenance is the closed a57 gate, never current dirty sources or target/debug.
$nativeManifestFile = Read-NativeFile (Join-Path $nativeRoot 'preparation-manifest.json')
if ($nativeManifestFile.sha256 -ne $nativeManifestHash) { throw 'Preparation manifest changed.' }
$nativeManifest = Get-Content -LiteralPath $nativeManifestFile.path -Raw | ConvertFrom-Json
if ($nativeManifest.source_commit -ne $nativeFreeze -or $nativeManifest.input_manifest_sha256 -ne $nativeInputHash -or $nativeManifest.frozen_input_count -ne 529) { throw 'Unexpected frozen input identity.' }
foreach ($file in $nativeManifest.files) {
$path = [IO.Path]::GetFullPath((Join-Path $nativeRoot $file.target))
if (-not $path.StartsWith($nativeRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'Manifest path escapes private root.' }
$actual = Read-NativeFile $path
if ($actual.sha256 -ne $file.sha256 -or $actual.bytes -ne $file.bytes) { throw "Prepared input changed: $($file.target)" }
}
$nativeInputs = Read-NativeFile (Join-Path $nativeRoot 'provenance/inputs.json')
if ($nativeInputs.sha256 -ne $nativeInputHash) { throw 'Frozen input manifest changed.' }
$nativeBuildFile = Read-NativeFile (Join-Path $nativeRoot 'provenance/studio-build-status.json')
$nativeBuild = Get-Content -LiteralPath $nativeBuildFile.path -Raw | ConvertFrom-Json
if ($nativeBuild.exit -ne 0 -or $nativeBuild.name -ne 'canvas-union-windows-20260911/studio-build' -or [string]::IsNullOrWhiteSpace($nativeBuild.completed_utc)) { throw 'Matching completed build required.' }
$nativeGui = Read-NativeFile (Join-Path $nativeBin 'arcadia-studio.exe')
$nativeDll = Read-NativeFile (Join-Path $nativeBin 'libunwind.dll')
if ($nativeGui.sha256 -ne $nativeGuiHash -or $nativeGui.bytes -ne 113028608 -or $nativeDll.sha256 -ne $nativeDllHash -or $nativeDll.bytes -ne 190464) { throw 'Immutable GUI/runtime mismatch.' }
if (-not [string]::Equals($env:SystemRoot, 'C:\Windows', [StringComparison]::OrdinalIgnoreCase)) { throw 'This recorded launch requires SystemRoot C:\Windows.' }
foreach ($directory in @($nativeProfile, $nativeCwd, (Join-Path $nativeRoot 'logs'), 'C:\Windows\System32')) {
if (-not (Read-NativeItem $directory).PSIsContainer) { throw "Required directory missing: $directory" }
}
foreach ($empty in @($nativeProfile, $nativeCwd)) {
if (@(Get-ChildItem -LiteralPath $empty -Force).Count -ne 0) { throw "Fresh empty directory required: $empty" }
}
foreach ($output in @($nativeReceipt, $nativeTerminal, $nativeStdout, $nativeStderr)) {
if (Test-Path -LiteralPath $output) { throw "Never overwrite prior launch evidence: $output" }
}
$nativeState = [ordered]@{
source_commit = $nativeFreeze
scope = 'Independent pre-grapheme a57 camera/clipping/zoom/hit session; no package or new combined Unicode acceptance implied.'
preparation_manifest = $nativeManifestFile
build_inputs_manifest = $nativeInputs
build_status_file = $nativeBuildFile
build_status = $nativeBuild
studio = $nativeGui
runtime = $nativeDll
launch_script = (Read-NativeFile $PSCommandPath)
process_path = $nativePath
isolated_localappdata = $nativeProfile
working_directory = $nativeCwd
arguments = @()
environment_scope = 'Child ProcessStartInfo only; other normal inherited environment retained.'
window_style = 'Hidden'
use_shell_execute = $false
create_no_window = $true
stdout = $nativeStdout
stderr = $nativeStderr
pid = $null
launch_requested_utc = $null
process_started_utc = $null
state = 'preflight_passed'
acceptance = 'Pending root observation and independent source/model checks.'
}
$nativeReceiptStream = $null
$nativeOutStream = $null
$nativeErrStream = $null
$nativeProcess = $null
$nativeStarted = $false
try {
# Exclusive receipt reservation also serializes accidental concurrent invocations.
$nativeReceiptStream = Open-NativeOutput $nativeReceipt
Write-NativeRecord $nativeState $nativeReceiptStream
$nativeOutStream = Open-NativeOutput $nativeStdout
$nativeErrStream = Open-NativeOutput $nativeStderr
$nativeInfo = [Diagnostics.ProcessStartInfo]::new()
$nativeInfo.FileName = $nativeGui.path
$nativeInfo.WorkingDirectory = $nativeCwd
$nativeInfo.UseShellExecute = $false
$nativeInfo.CreateNoWindow = $true
$nativeInfo.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden
$nativeInfo.RedirectStandardOutput = $true
$nativeInfo.RedirectStandardError = $true
$nativeInfo.Environment['PATH'] = $nativePath
$nativeInfo.Environment['LOCALAPPDATA'] = $nativeProfile
foreach ($file in @($nativeGui, $nativeDll, $nativeManifestFile, $nativeInputs, $nativeBuildFile)) {
if ((Read-NativeFile $file.path).sha256 -ne $file.sha256) { throw "Launch-critical input changed: $($file.path)" }
}
$nativeProcess = [Diagnostics.Process]::new()
$nativeProcess.StartInfo = $nativeInfo
$nativeState.launch_requested_utc = [DateTime]::UtcNow.ToString('o')
if (-not $nativeProcess.Start()) { throw 'Process.Start returned false.' }
$nativeStarted = $true
$nativeState.pid = $nativeProcess.Id
# Pump both raw streams concurrently; no line decoding/newline normalization.
$nativeOutCopy = $nativeProcess.StandardOutput.BaseStream.CopyToAsync($nativeOutStream)
$nativeErrCopy = $nativeProcess.StandardError.BaseStream.CopyToAsync($nativeErrStream)
$nativeState.process_started_utc = $nativeProcess.StartTime.ToUniversalTime().ToString('o')
$nativeState.state = 'launched'
Write-NativeRecord $nativeState $nativeReceiptStream
$nativeReceiptStream.Dispose()
$nativeReceiptStream = $null
$nativeState | ConvertTo-Json -Depth 12 | Write-Output
# Deliberately stays attached as a logger. Root uses a running shell session and closes GUI normally.
$nativeProcess.WaitForExit()
[Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($nativeOutCopy, $nativeErrCopy))
$nativeOutStream.Flush($true)
$nativeErrStream.Flush($true)
$nativeOutStream.Dispose()
$nativeOutStream = $null
$nativeErrStream.Dispose()
$nativeErrStream = $null
$nativeClosed = [ordered]@{
source_commit = $nativeFreeze
pid = $nativeState.pid
process_started_utc = $nativeState.process_started_utc
observed_exit_utc = [DateTime]::UtcNow.ToString('o')
exit_code = $nativeProcess.ExitCode
scope = 'Process termination and closed raw logs only; UI acceptance and reason for close require root observation.'
stdout = (Read-NativeFile $nativeStdout)
stderr = (Read-NativeFile $nativeStderr)
studio_readback = (Read-NativeFile $nativeGui.path)
runtime_readback = (Read-NativeFile $nativeDll.path)
}
$nativeCloseStream = Open-NativeOutput $nativeTerminal
try { Write-NativeRecord $nativeClosed $nativeCloseStream } finally { $nativeCloseStream.Dispose() }
$nativeClosed | ConvertTo-Json -Depth 12 | Write-Output
} catch {
if ($null -ne $nativeReceiptStream) {
$nativeState.state = if ($nativeStarted) { 'launched_record_error' } else { 'launch_failed' }
$nativeState['error'] = $_.Exception.Message
Write-NativeRecord $nativeState $nativeReceiptStream
}
throw
} finally {
if ($null -ne $nativeReceiptStream) { $nativeReceiptStream.Dispose() }
if ($null -ne $nativeOutStream) { $nativeOutStream.Dispose() }
if ($null -ne $nativeErrStream) { $nativeErrStream.Dispose() }
if ($null -ne $nativeProcess) { $nativeProcess.Dispose() }
# Never kill an application here. Root owns every native interaction and any recovery.
}
@@ -0,0 +1,5 @@
{
"scope": "Parser only; no launch",
"errors": [],
"pass": true
}
@@ -0,0 +1,67 @@
{
"source_commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
"scope": "Independent pre-grapheme a57 camera/clipping/zoom/hit session; no package or new combined Unicode acceptance implied.",
"preparation_manifest": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\preparation-manifest.json",
"bytes": 9149,
"sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB"
},
"build_inputs_manifest": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\inputs.json",
"bytes": 101859,
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
},
"build_status_file": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\studio-build-status.json",
"bytes": 474,
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
},
"build_status": {
"seconds": 385.1131455,
"log": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\studio-build.log",
"started_utc": "2026-09-11T20:14:23.6348444Z",
"exit": 0,
"command": [
"cargo",
"build",
"-p",
"oa-desktop",
"--bin",
"arcadia-studio",
"--offline",
"--locked"
],
"name": "canvas-union-windows-20260911/studio-build",
"completed_utc": "2026-09-11T20:20:48.7677661Z"
},
"studio": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\arcadia-studio.exe",
"bytes": 113028608,
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
},
"runtime": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\libunwind.dll",
"bytes": 190464,
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
},
"launch_script": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\launch-native-a57c334-camera.ps1",
"bytes": 10001,
"sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE"
},
"process_path": "C:\\Windows\\System32;C:\\Windows;C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin",
"isolated_localappdata": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\profile",
"working_directory": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\unrelated-cwd",
"arguments": [],
"environment_scope": "Child ProcessStartInfo only; other normal inherited environment retained.",
"window_style": "Hidden",
"use_shell_execute": false,
"create_no_window": true,
"stdout": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stdout.log",
"stderr": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stderr.log",
"pid": 5004,
"launch_requested_utc": "2026-09-12T10:52:56.9766659Z",
"process_started_utc": "2026-09-12T10:52:57.0962185Z",
"state": "launched",
"acceptance": "Pending root observation and independent source/model checks."
}
@@ -0,0 +1 @@
PASS same: all identities, fields, reachable source and physical bytes
@@ -0,0 +1,347 @@
{
"created_utc": "2026-09-12T11:07:20.1744786Z",
"scope": "Closed a57 camera evidence only; copying does not execute gates or UI",
"payload_count": 47,
"payload_bytes": 2184242.0,
"files": [
{
"path": "baseline-wide/project.arcadia",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\baseline-wide\\project.arcadia",
"bytes": 20128,
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
},
{
"path": "baseline-wide/rationale.arcadia",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\baseline-wide\\rationale.arcadia",
"bytes": 86,
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
},
{
"path": "checkpoints/wide-passive/project.arcadia",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\checkpoints\\wide-passive\\project.arcadia",
"bytes": 20128,
"original_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3",
"copy_sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
},
{
"path": "checkpoints/wide-passive/rationale.arcadia",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\checkpoints\\wide-passive\\rationale.arcadia",
"bytes": 86,
"original_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF",
"copy_sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
},
{
"path": "launch-script-syntax.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\launch-script-syntax.json",
"bytes": 77,
"original_sha256": "9FE1369737B8A02EE9DDB83318CE800F200AFABF5486C9DD6F52DBEC9F87502C",
"copy_sha256": "9FE1369737B8A02EE9DDB83318CE800F200AFABF5486C9DD6F52DBEC9F87502C"
},
{
"path": "launch.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\launch.json",
"bytes": 3308,
"original_sha256": "7BCF09DFB689DB4E686BBACD0F1805B013597FB4159D5477D5DE56F52D79D147",
"copy_sha256": "7BCF09DFB689DB4E686BBACD0F1805B013597FB4159D5477D5DE56F52D79D147"
},
{
"path": "logs/studio.stderr.log",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stderr.log",
"bytes": 0,
"original_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
"copy_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
},
{
"path": "logs/studio.stdout.log",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stdout.log",
"bytes": 0,
"original_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
"copy_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
},
{
"path": "logs/wide-passive.stderr.log",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\wide-passive.stderr.log",
"bytes": 0,
"original_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
"copy_sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
},
{
"path": "logs/wide-passive.stdout.log",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\wide-passive.stdout.log",
"bytes": 71,
"original_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477",
"copy_sha256": "72F1D2214D32F2C77CEB6666FFA91EB54795BC94E77DAF63589B00ABFEAAC477"
},
{
"path": "normal-close.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\normal-close.json",
"bytes": 1326,
"original_sha256": "21CE20643ED69CA1247B261A44A71A562850D0BCF8339761E60F11B7A19A7AF1",
"copy_sha256": "21CE20643ED69CA1247B261A44A71A562850D0BCF8339761E60F11B7A19A7AF1"
},
{
"path": "preparation-manifest.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\preparation-manifest.json",
"bytes": 9149,
"original_sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB",
"copy_sha256": "293892C2F430D6EADB58C6678F3C1B346742B3AA4D2FE5CE3729F896EAD2C2DB"
},
{
"path": "prepare-inputs.py",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\prepare-inputs.py",
"bytes": 5563,
"original_sha256": "661D6686AD0B59FBD28CB4FE42CFD4955DB5922B9CE3B5EF3F378A4CF1982884",
"copy_sha256": "661D6686AD0B59FBD28CB4FE42CFD4955DB5922B9CE3B5EF3F378A4CF1982884"
},
{
"path": "provenance/after-build-verify-head.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\after-build-verify-head.json",
"bytes": 199,
"original_sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08",
"copy_sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08"
},
{
"path": "provenance/binary-manifest.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\binary-manifest.json",
"bytes": 2828,
"original_sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA",
"copy_sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA"
},
{
"path": "provenance/desktop-all-targets-status.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\desktop-all-targets-status.json",
"bytes": 472,
"original_sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0",
"copy_sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0"
},
{
"path": "provenance/desktop-clippy-status.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\desktop-clippy-status.json",
"bytes": 503,
"original_sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C",
"copy_sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C"
},
{
"path": "provenance/focused-lifecycle-status.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\focused-lifecycle-status.json",
"bytes": 509,
"original_sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3",
"copy_sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3"
},
{
"path": "provenance/inputs.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\inputs.json",
"bytes": 101859,
"original_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
"copy_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
},
{
"path": "provenance/lifecycle-fixture-README.md",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\lifecycle-fixture-README.md",
"bytes": 12199,
"original_sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78",
"copy_sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78"
},
{
"path": "provenance/lifecycle-native-contract.rs",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\lifecycle-native-contract.rs",
"bytes": 19135,
"original_sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4",
"copy_sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4"
},
{
"path": "provenance/lifecycle-oracle-status.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\lifecycle-oracle-status.json",
"bytes": 1272,
"original_sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C",
"copy_sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C"
},
{
"path": "provenance/oracles-865-copy-manifest.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\oracles-865-copy-manifest.json",
"bytes": 1015,
"original_sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C",
"copy_sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C"
},
{
"path": "provenance/studio-build-status.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\studio-build-status.json",
"bytes": 474,
"original_sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC",
"copy_sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
},
{
"path": "provenance/typography-admission.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\typography-admission.json",
"bytes": 553,
"original_sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8",
"copy_sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8"
},
{
"path": "provenance/typography-predictions.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\provenance\\typography-predictions.json",
"bytes": 2490,
"original_sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243",
"copy_sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243"
},
{
"path": "screenshots/01-wide-opened.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\01-wide-opened.jpg",
"bytes": 102087,
"original_sha256": "76B5ABF72AAA2B40DB91FCF182E3B464BEE29CC9ABDCCC4005EC9567554C7E18",
"copy_sha256": "76B5ABF72AAA2B40DB91FCF182E3B464BEE29CC9ABDCCC4005EC9567554C7E18"
},
{
"path": "screenshots/02-behavior-open.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\02-behavior-open.jpg",
"bytes": 118609,
"original_sha256": "92D75BCF44D78221D99C393C039E6ED395AF70973477643EEF437CD0B5EFF1A2",
"copy_sha256": "92D75BCF44D78221D99C393C039E6ED395AF70973477643EEF437CD0B5EFF1A2"
},
{
"path": "screenshots/03-oa-create-selected-100.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\03-oa-create-selected-100.jpg",
"bytes": 134137,
"original_sha256": "0028B918C56C5686890C18D730D3394CC07A8D0714DBD5175B06A336C2BD937C",
"copy_sha256": "0028B918C56C5686890C18D730D3394CC07A8D0714DBD5175B06A336C2BD937C"
},
{
"path": "screenshots/04-oa-create-zoom125.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\04-oa-create-zoom125.jpg",
"bytes": 131774,
"original_sha256": "9B6627543E8A79E8D217029861407A89C884F36A3024BE44F5B8205AD2B557ED",
"copy_sha256": "9B6627543E8A79E8D217029861407A89C884F36A3024BE44F5B8205AD2B557ED"
},
{
"path": "screenshots/05-oa-delete-selected-125.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\05-oa-delete-selected-125.jpg",
"bytes": 132774,
"original_sha256": "1BC820418F12B18EBC97F52D2B2B85B8B9BDD57F9485BE040946FC9CC2F37FAA",
"copy_sha256": "1BC820418F12B18EBC97F52D2B2B85B8B9BDD57F9485BE040946FC9CC2F37FAA"
},
{
"path": "screenshots/06-oa-fit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\06-oa-fit.jpg",
"bytes": 133100,
"original_sha256": "585063787D8E0299A7C2173BB8EC0264CDF51D5EDD8CBC3FCC953EF1917E1116",
"copy_sha256": "585063787D8E0299A7C2173BB8EC0264CDF51D5EDD8CBC3FCC953EF1917E1116"
},
{
"path": "screenshots/07-minimum-width.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\07-minimum-width.jpg",
"bytes": 123533,
"original_sha256": "9D798D0B087C7F05FAFDC23237BD934B069B69E1A08D8C33EAF74D4E85264582",
"copy_sha256": "9D798D0B087C7F05FAFDC23237BD934B069B69E1A08D8C33EAF74D4E85264582"
},
{
"path": "screenshots/08-minimum-window.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\08-minimum-window.jpg",
"bytes": 105379,
"original_sha256": "DFBAFFA375445BCD36565BDC8873DBF457E0A528A827E882F069B95D90529944",
"copy_sha256": "DFBAFFA375445BCD36565BDC8873DBF457E0A528A827E882F069B95D90529944"
},
{
"path": "screenshots/09-oa-minimum-fit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\09-oa-minimum-fit.jpg",
"bytes": 99033,
"original_sha256": "FD2FEE1D602488243876B6FAEAFCBBCFE7065DDCFA2C28AD743A0FD19673C4B9",
"copy_sha256": "FD2FEE1D602488243876B6FAEAFCBBCFE7065DDCFA2C28AD743A0FD19673C4B9"
},
{
"path": "screenshots/10-oa-minimum-create-hit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\10-oa-minimum-create-hit.jpg",
"bytes": 99898,
"original_sha256": "7975C21C9E41D32A8684E3420DEF616D7AC1E43F5EF291E94380FA63DA37241F",
"copy_sha256": "7975C21C9E41D32A8684E3420DEF616D7AC1E43F5EF291E94380FA63DA37241F"
},
{
"path": "screenshots/11-oa-minimum-pan-right.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\11-oa-minimum-pan-right.jpg",
"bytes": 99456,
"original_sha256": "61BD5C917B738E1C3B2267D623A41193A8C5F84F5A1F7B51DAFA1B147DB13A51",
"copy_sha256": "61BD5C917B738E1C3B2267D623A41193A8C5F84F5A1F7B51DAFA1B147DB13A51"
},
{
"path": "screenshots/12-oa-panned-delete-hit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\12-oa-panned-delete-hit.jpg",
"bytes": 98952,
"original_sha256": "1B9C0E408BC9445AE0E6BDC2F86EA341AA385EB26D42693C76309963D9D4064C",
"copy_sha256": "1B9C0E408BC9445AE0E6BDC2F86EA341AA385EB26D42693C76309963D9D4064C"
},
{
"path": "screenshots/13-system-layer.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\13-system-layer.jpg",
"bytes": 101904,
"original_sha256": "02992B658775E55F0CBAF974050BBFEA0C1F608799C5D9BE9A05AA6789C9F1F4",
"copy_sha256": "02992B658775E55F0CBAF974050BBFEA0C1F608799C5D9BE9A05AA6789C9F1F4"
},
{
"path": "screenshots/14-sa-minimum-fit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\14-sa-minimum-fit.jpg",
"bytes": 95469,
"original_sha256": "E4A274CA8DA7ECD2255EF519C8FC81D8310DB55C9DB9009FC8731751A25202CB",
"copy_sha256": "E4A274CA8DA7ECD2255EF519C8FC81D8310DB55C9DB9009FC8731751A25202CB"
},
{
"path": "screenshots/15-sa-minimum-create-hit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\15-sa-minimum-create-hit.jpg",
"bytes": 99837,
"original_sha256": "4ED02839A417EE567CD8F23750DFFB535AF8C32063B535F8C0B4BE8ACB082542",
"copy_sha256": "4ED02839A417EE567CD8F23750DFFB535AF8C32063B535F8C0B4BE8ACB082542"
},
{
"path": "screenshots/16-sa-pan-up.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\16-sa-pan-up.jpg",
"bytes": 96836,
"original_sha256": "26048071F3B2A09ED054D18834C128093CC1FB9B1C4A7518B09E0F9E9EC8291A",
"copy_sha256": "26048071F3B2A09ED054D18834C128093CC1FB9B1C4A7518B09E0F9E9EC8291A"
},
{
"path": "screenshots/17-sa-panned-delete-hit.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\17-sa-panned-delete-hit.jpg",
"bytes": 96499,
"original_sha256": "31EA2A24959B4A5014C9FCD0E43BD918847C6FB58EB0B8B1577C56074E266BC0",
"copy_sha256": "31EA2A24959B4A5014C9FCD0E43BD918847C6FB58EB0B8B1577C56074E266BC0"
},
{
"path": "screenshots/18-passive-navigation-saved.jpg",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\screenshots\\18-passive-navigation-saved.jpg",
"bytes": 94345,
"original_sha256": "280942F9B9806884D4EE34A5FB740428A420D7603EDDDF21D6E4979BEE041B2D",
"copy_sha256": "280942F9B9806884D4EE34A5FB740428A420D7603EDDDF21D6E4979BEE041B2D"
},
{
"path": "second-readback.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\second-readback.json",
"bytes": 6332,
"original_sha256": "BB8063AAC3FFF3A7B8FC19BA81F9EBBC13068871F6924651A14B08D58CE525BB",
"copy_sha256": "BB8063AAC3FFF3A7B8FC19BA81F9EBBC13068871F6924651A14B08D58CE525BB"
},
{
"path": "wide-passive-status.json",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\wide-passive-status.json",
"bytes": 857,
"original_sha256": "A358FA3C26A11DEACFC9457EC96F27F43EBD9020575329E701F0F026759850BF",
"copy_sha256": "A358FA3C26A11DEACFC9457EC96F27F43EBD9020575329E701F0F026759850BF"
},
{
"path": "launch-native-a57c334-camera.ps1",
"original": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\launch-native-a57c334-camera.ps1",
"bytes": 10001,
"original_sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE",
"copy_sha256": "BE80A4B5B407BCEA3DBECC547DA5E5888F3C6DC06F5F7AF87F546C388963B4DE"
}
],
"excluded": [
"bin",
"oracles executables/DLL",
"baseline-clusters",
"working-wide",
"working-clusters",
"profile",
"unrelated-cwd",
"ZIP archives"
]
}
@@ -0,0 +1,28 @@
{
"source_commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
"pid": 5004,
"process_started_utc": "2026-09-12T10:52:57.0962185Z",
"observed_exit_utc": "2026-09-12T11:02:56.0410831Z",
"exit_code": 0,
"scope": "Process termination and closed raw logs only; UI acceptance and reason for close require root observation.",
"stdout": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stdout.log",
"bytes": 0,
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
},
"stderr": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\logs\\studio.stderr.log",
"bytes": 0,
"sha256": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
},
"studio_readback": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\arcadia-studio.exe",
"bytes": 113028608,
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
},
"runtime_readback": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin\\libunwind.dll",
"bytes": 190464,
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
}
}
@@ -0,0 +1,166 @@
{
"prepared_utc": "2026-09-11T21:32:45.779806+00:00",
"source_commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
"scope": "Preparation only for independent OA/SA camera, clipping, minimum-size, zoom and hit checks with passive Save equality. Pre-grapheme a57; no package or new combined Unicode acceptance.",
"archive": {
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip",
"sha256": "E2C8BB3CC8EB2BFD6ABB0A2C5769756148D594CE0CDC90449A894AB0CB026927"
},
"input_manifest_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
"frozen_input_count": 529,
"files": [
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/rationale.arcadia",
"target": "baseline-wide/rationale.arcadia",
"bytes": 86,
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/rationale.arcadia",
"target": "working-wide/rationale.arcadia",
"bytes": 86,
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/project.arcadia",
"target": "baseline-wide/project.arcadia",
"bytes": 20128,
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-wide/project.arcadia",
"target": "working-wide/project.arcadia",
"bytes": 20128,
"sha256": "6A18EE1F246321BDF8AB0E7E8E5D4EBA134CE486ED5224C4FFD61C7342C3A7D3"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/project.arcadia",
"target": "baseline-clusters/project.arcadia",
"bytes": 20110,
"sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/project.arcadia",
"target": "working-clusters/project.arcadia",
"bytes": 20110,
"sha256": "EECDDFC4A4514E995633EA58B89FE154DC6DC43509B1F53EE05B76F8E6B993C8"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/rationale.arcadia",
"target": "baseline-clusters/rationale.arcadia",
"bytes": 86,
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/typography-clusters/rationale.arcadia",
"target": "working-clusters/rationale.arcadia",
"bytes": 86,
"sha256": "8509F8D8A136D808CF98A1BB0FE50348B8A24409F30DEE4EE65B62BF5CB9E6BF"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\exact-inputs.zip::harness/fixtures/scenario-lifecycle-native/README.md",
"target": "provenance/lifecycle-fixture-README.md",
"bytes": 12199,
"sha256": "B441C562459B2AE000055D73529FB608C9CC015F64293206B9FB7D8C7CBC8B78"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\bin\\arcadia-studio.exe",
"target": "bin/arcadia-studio.exe",
"bytes": 113028608,
"sha256": "69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\bin\\libunwind.dll",
"target": "bin/libunwind.dll",
"bytes": 190464,
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\inputs.json",
"target": "provenance/inputs.json",
"bytes": 101859,
"sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\binary-manifest.json",
"target": "provenance/binary-manifest.json",
"bytes": 2828,
"sha256": "F8F8A97B2BA516685A0690E98DF90269A7C83F371A5A3257E80B865E15D3EDFA"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\after-build-verify-head.json",
"target": "provenance/after-build-verify-head.json",
"bytes": 199,
"sha256": "FF0F533653B601F1456C726106A97D94346C3C18288912C620748112119AFF08"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\studio-build-status.json",
"target": "provenance/studio-build-status.json",
"bytes": 474,
"sha256": "AB1E04807F32F8DADA0A572D69F632BD6FD242150DF211C18E35D82EA8BE91EC"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-clippy-status.json",
"target": "provenance/desktop-clippy-status.json",
"bytes": 503,
"sha256": "BA5F6D17354D194C1102EA75EE4E7B893EF04EEDD21518D591726A912E3E6A4C"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\desktop-all-targets-status.json",
"target": "provenance/desktop-all-targets-status.json",
"bytes": 472,
"sha256": "2C23196BEA849E77C49F2CF64FD31311A01998520460E92F91B935C5A7880BC0"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\focused-lifecycle-status.json",
"target": "provenance/focused-lifecycle-status.json",
"bytes": 509,
"sha256": "FA6618F15C4B41BDD691A613CF0992C83CE984A1AA0FAFFCD2DC4FB96CDEBCE3"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\oracles\\lifecycle.exe",
"target": "oracles/lifecycle.exe",
"bytes": 20700160,
"sha256": "798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\oracles\\libunwind.dll",
"target": "oracles/libunwind.dll",
"bytes": 190464,
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\harness\\lifecycle-native-contract.rs",
"target": "provenance/lifecycle-native-contract.rs",
"bytes": 19135,
"sha256": "FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-865483b\\oracles\\manifest.json",
"target": "provenance/oracles-865-copy-manifest.json",
"bytes": 1015,
"sha256": "5DC8D7A3C721E03A0A249E5B8D30BC41539B39D39EC7157D9DBF5C99044B1C4C"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\lifecycle-native-ytr83r-20260911\\status.json",
"target": "provenance/lifecycle-oracle-status.json",
"bytes": 1272,
"sha256": "68452154CF96EC4FD245D89A11D1FC1C08CB471419A5F8D94039ECCBF2EFCF7C"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\harness\\experiments\\lifecycle-native-ytr83r-20260911\\typography-admission.json",
"target": "provenance/typography-admission.json",
"bytes": 553,
"sha256": "B06FD83F554982B407E1B28A79DFADCB61717B4F076EB3062EC314C742A2CCD8"
},
{
"origin": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\harness\\experiments\\lifecycle-native-ytr83r-20260911\\typography-predictions.json",
"target": "provenance/typography-predictions.json",
"bytes": 2490,
"sha256": "6951FF7C96C0A7A14D72781B416FC64CAB9F4AF5861F5C0EF4C327D843483243"
}
],
"empty_profile": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\profile",
"unrelated_cwd": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\unrelated-cwd",
"restricted_path": "C:\\Windows\\System32;C:\\Windows;C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-a57c334-camera\\bin"
}
@@ -0,0 +1,88 @@
"""Preparation only: copy and hash frozen inputs; never execute model/oracle/GUI code."""
import hashlib
import json
import shutil
import zipfile
from datetime import datetime, timezone
from pathlib import Path
root = Path(__file__).resolve().parent
repo = root.parent.parent
gates = repo / '.local/canvas-union-windows-20260911'
freeze = 'a57c334b302eb13352b4bb65df1c75d94d002348'
def digest(data):
return hashlib.sha256(data).hexdigest().upper()
def write_new(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('xb') as out:
out.write(data)
records = []
def copy_exact(source, target, expected=None):
source = Path(source)
data = source.read_bytes()
sha = digest(data)
if expected:
assert sha == expected.upper(), (source, sha, expected)
destination = root / target
write_new(destination, data)
assert destination.read_bytes() == data == source.read_bytes()
records.append(dict(origin=str(source), target=target, bytes=len(data), sha256=sha))
assert not (root / 'preparation-manifest.json').exists(), 'Never overwrite a prior preparation'
manifest_bytes = (gates / 'inputs.json').read_bytes()
assert digest(manifest_bytes) == '47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3'
inputs = json.loads(manifest_bytes)
indexed = {item['path']: item for item in inputs['files']}
assert len(indexed) == 529
archive = gates / 'exact-inputs.zip'
archive_sha = digest(archive.read_bytes())
with zipfile.ZipFile(archive) as frozen:
selected = {
'harness/fixtures/scenario-lifecycle-native/typography-wide/rationale.arcadia': ('baseline-wide/rationale.arcadia', 'working-wide/rationale.arcadia'),
'harness/fixtures/scenario-lifecycle-native/typography-wide/project.arcadia': ('baseline-wide/project.arcadia', 'working-wide/project.arcadia'),
'harness/fixtures/scenario-lifecycle-native/typography-clusters/project.arcadia': ('baseline-clusters/project.arcadia', 'working-clusters/project.arcadia'),
'harness/fixtures/scenario-lifecycle-native/typography-clusters/rationale.arcadia': ('baseline-clusters/rationale.arcadia', 'working-clusters/rationale.arcadia'),
'harness/fixtures/scenario-lifecycle-native/README.md': ('provenance/lifecycle-fixture-README.md',),
}
for name, destinations in selected.items():
data = frozen.read(name)
record = indexed[name]
assert len(data) == record['bytes'] and digest(data) == record['sha256'].upper(), name
for destination in destinations:
write_new(root / destination, data)
records.append(dict(origin=str(archive) + '::' + name, target=destination, bytes=len(data), sha256=digest(data)))
copy_exact(gates / 'bin/arcadia-studio.exe', 'bin/arcadia-studio.exe', '69DBB90FA299B3B3D90140C7934891F79166025893AF9B35B2B8D754A37A0D40')
copy_exact(gates / 'bin/libunwind.dll', 'bin/libunwind.dll', 'AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A')
for name in ('inputs.json', 'binary-manifest.json', 'after-build-verify-head.json', 'studio-build-status.json', 'desktop-clippy-status.json', 'desktop-all-targets-status.json', 'focused-lifecycle-status.json'):
copy_exact(gates / name, 'provenance/' + name)
for source, target, expected in (
('.local/native-865483b/oracles/lifecycle.exe', 'oracles/lifecycle.exe', '798D7A1907B894C13549BAD35511B21D6BC7F3D67D63674042B7DF71184C7524'),
('.local/native-865483b/oracles/libunwind.dll', 'oracles/libunwind.dll', 'AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A'),
('harness/lifecycle-native-contract.rs', 'provenance/lifecycle-native-contract.rs', 'FC5E71F9870D7B98E0F52EAF20C831EBFFBE9F7A9B8960269DC9BE3D9BF0D9E4'),
):
copy_exact(repo / source, target, expected)
for source, target in (
('.local/native-865483b/oracles/manifest.json', 'provenance/oracles-865-copy-manifest.json'),
('.local/lifecycle-native-ytr83r-20260911/status.json', 'provenance/lifecycle-oracle-status.json'),
('harness/experiments/lifecycle-native-ytr83r-20260911/typography-admission.json', 'provenance/typography-admission.json'),
('harness/experiments/lifecycle-native-ytr83r-20260911/typography-predictions.json', 'provenance/typography-predictions.json'),
):
copy_exact(repo / source, target)
for directory in ('profile', 'unrelated-cwd', 'logs', 'screenshots', 'checkpoints'):
(root / directory).mkdir(exist_ok=False)
for record in records:
if record['target'].startswith(('baseline-', 'bin/', 'oracles/', 'provenance/')):
(root / record['target']).chmod(0o444)
result = dict(prepared_utc=datetime.now(timezone.utc).isoformat(), source_commit=freeze,
scope='Preparation only for independent OA/SA camera, clipping, minimum-size, zoom and hit checks with passive Save equality. Pre-grapheme a57; no package or new combined Unicode acceptance.',
archive=dict(path=str(archive), sha256=archive_sha), input_manifest_sha256=digest(manifest_bytes),
frozen_input_count=529, files=records,
empty_profile=str(root / 'profile'), unrelated_cwd=str(root / 'unrelated-cwd'),
restricted_path='C:\\Windows\\System32;C:\\Windows;' + str(root / 'bin'))
write_new(root / 'preparation-manifest.json', (json.dumps(result, indent=2) + '\n').encode())
print(json.dumps(dict(files=len(records), copied_bytes=sum(r['bytes'] for r in records), archive_sha256=archive_sha), indent=2))

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