Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27df4a3945 | ||
|
|
6a610616e7 | ||
|
|
01b76a1e89 | ||
|
|
2ec72d4241 | ||
|
|
4bb4aaf3dc | ||
|
|
d114a271c5 | ||
|
|
3978bbe23c | ||
|
|
81668f94ed | ||
|
|
ee97fcb1e3 | ||
|
|
b98f135799 | ||
|
|
a70de55ecd | ||
|
|
31b7dc3e15 | ||
|
|
057edddef8 | ||
|
|
b24a97360f | ||
|
|
7d1df4f369 |
+9
-40
@@ -15,58 +15,27 @@
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
PROJECT_GOAL.md -text whitespace=cr-at-eol
|
||||
harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
|
||||
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
|
||||
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
|
||||
|
||||
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
|
||||
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
|
||||
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
|
||||
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
|
||||
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
|
||||
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
|
||||
harness/release-native-child-continuation-review-20260911.md -text -whitespace
|
||||
|
||||
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
|
||||
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
|
||||
|
||||
@@ -1,65 +1,14 @@
|
||||
# Agent working agreement
|
||||
## Cooperation through Gitea MCP
|
||||
|
||||
## Working method
|
||||
Use Gitea MCP to coordinate with other agents working on this project.
|
||||
|
||||
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
|
||||
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
|
||||
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
|
||||
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
|
||||
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
|
||||
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
|
||||
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
|
||||
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
|
||||
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
|
||||
- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions.
|
||||
- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates.
|
||||
- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running.
|
||||
- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits.
|
||||
- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes.
|
||||
- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps.
|
||||
- Publish versioned Gitea releases at meaningful, working milestones so the user can see progress and try the application. Include runnable builds or appropriate distributable artifacts, concise release notes, launch instructions, known limitations, and what was verified. Mark unfinished versions as prereleases. Coordinate release ownership to avoid duplicates.
|
||||
- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly.
|
||||
|
||||
## User input
|
||||
|
||||
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
|
||||
|
||||
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
|
||||
|
||||
## Shared records
|
||||
|
||||
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
|
||||
|
||||
| Information | Authoritative home |
|
||||
| --- | --- |
|
||||
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
|
||||
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
|
||||
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
|
||||
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
|
||||
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
|
||||
| Published deliverables | Gitea releases, tied to source commits and verification. |
|
||||
|
||||
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
|
||||
|
||||
## Coordination and integration
|
||||
|
||||
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.
|
||||
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 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.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
|
||||
|
||||
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
|
||||
|
||||
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
|
||||
|
||||
## Delivery
|
||||
|
||||
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
|
||||
|
||||
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
|
||||
Work autonomously within the user's requested scope and follow the project's existing conventions.
|
||||
|
||||
Generated
-1
@@ -2176,7 +2176,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+600
-32
@@ -1,45 +1,612 @@
|
||||
# Project goal
|
||||
You are the principal investigator, chief systems engineer, product architect, and engineering orchestrator responsible for taking this project from its current state to a coherent, validated, working release.
|
||||
|
||||
## Objective
|
||||
Your responsibility is not merely to write software. Your responsibility is to solve the underlying problem end-to-end.
|
||||
|
||||
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
|
||||
The project may involve research, domain understanding, methodology design, semantics, formal specification, product design, UX, architecture, experimentation, implementation, testing, documentation, and integration. Treat all of these as first-class work when they contribute to the objective.
|
||||
|
||||
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
|
||||
Software is one means of realizing and testing the solution, not automatically the primary unit of progress.
|
||||
|
||||
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
|
||||
## Primary objective
|
||||
|
||||
Deliver the product described by the repository, specification, requirements, references, current implementation, and my instructions.
|
||||
|
||||
Persist until the intended outcome is genuinely achieved.
|
||||
|
||||
Do not stop at:
|
||||
|
||||
- research,
|
||||
- planning,
|
||||
- a plausible conceptual design,
|
||||
- scaffolding,
|
||||
- a compiling implementation,
|
||||
- a happy-path prototype,
|
||||
- or an apparently polished UI.
|
||||
|
||||
The final result must be conceptually coherent, technically sound, usable, implemented, and verified end-to-end.
|
||||
|
||||
Bias toward action and completion.
|
||||
|
||||
Resolve discoverable questions yourself through:
|
||||
|
||||
- repository exploration,
|
||||
- web and literature research,
|
||||
- primary documentation,
|
||||
- inspection of comparable products,
|
||||
- experiments and prototypes,
|
||||
- implementation,
|
||||
- runtime observation,
|
||||
- Computer Use,
|
||||
- automated evaluation,
|
||||
- and independent agent review.
|
||||
|
||||
Ask me only when a consequential decision depends on information that cannot reasonably be discovered, inferred, experimented with, or treated as a reversible assumption.
|
||||
|
||||
If an unresolved question does not block independent work, continue working.
|
||||
|
||||
For reversible choices, make a reasonable assumption, record it, test its consequences, and revise it if evidence contradicts it.
|
||||
|
||||
## Operating model
|
||||
|
||||
Use an adaptive orchestrator-worker workflow.
|
||||
|
||||
Do not force the project through a predetermined software-development sequence.
|
||||
|
||||
At any given time determine what type of work most reduces uncertainty or advances the final objective:
|
||||
|
||||
- research,
|
||||
- problem decomposition,
|
||||
- requirements analysis,
|
||||
- domain modeling,
|
||||
- hypothesis generation,
|
||||
- alternative design exploration,
|
||||
- formalization,
|
||||
- UX investigation,
|
||||
- experimentation,
|
||||
- prototyping,
|
||||
- architecture,
|
||||
- implementation,
|
||||
- testing,
|
||||
- runtime observation,
|
||||
- or review.
|
||||
|
||||
Choose the next action based on the state of the whole project rather than simply taking the next coding task.
|
||||
|
||||
## Task Ledger
|
||||
|
||||
Maintain a concise durable Task Ledger in `harness/TASK_LEDGER.md`, unless an equivalent artifact already exists.
|
||||
|
||||
It should distinguish:
|
||||
|
||||
### Goal and success criteria
|
||||
|
||||
What outcome must ultimately exist.
|
||||
|
||||
### Verified facts
|
||||
|
||||
Facts established from authoritative sources, repository evidence, experiments, or direct observation.
|
||||
|
||||
### Given facts
|
||||
|
||||
Requirements or constraints explicitly supplied by me.
|
||||
|
||||
### Assumptions
|
||||
|
||||
Things currently treated as true but not yet verified.
|
||||
|
||||
### Open questions
|
||||
|
||||
Important unknowns.
|
||||
|
||||
### Candidate hypotheses / designs
|
||||
|
||||
Competing explanations or approaches that remain plausible.
|
||||
|
||||
### Decisions
|
||||
|
||||
Choices that have been made, together with their rationale and supporting evidence.
|
||||
|
||||
### Current strategy
|
||||
|
||||
The current high-level route toward the goal.
|
||||
|
||||
Do not silently convert an assumption into a fact.
|
||||
|
||||
Do not silently convert the first plausible idea into a decision.
|
||||
|
||||
Update the Task Ledger when important knowledge changes.
|
||||
|
||||
## Progress Ledger
|
||||
|
||||
Maintain `harness/STATE.md` as the Progress Ledger.
|
||||
|
||||
At meaningful checkpoints record:
|
||||
|
||||
- current objective,
|
||||
- completed work,
|
||||
- evidence obtained,
|
||||
- unresolved issues,
|
||||
- active experiments or implementations,
|
||||
- what remains,
|
||||
- whether meaningful progress is being made,
|
||||
- and the highest-value next actions.
|
||||
|
||||
Periodically ask:
|
||||
|
||||
1. Are we measurably closer to the actual end goal?
|
||||
2. Are we solving the right problem?
|
||||
3. Are we repeating an approach that is not working?
|
||||
4. Has new evidence invalidated part of the plan?
|
||||
5. Are important unknowns being hidden underneath implementation work?
|
||||
6. Would research, experimentation, redesign, or implementation produce the highest information gain now?
|
||||
|
||||
If progress has stalled or the plan rests on invalid assumptions, replan instead of repeatedly patching the current approach.
|
||||
|
||||
## Durable decisions and evidence
|
||||
|
||||
Maintain `harness/DECISIONS.md` for important long-lived decisions.
|
||||
|
||||
For consequential decisions capture:
|
||||
|
||||
- the problem,
|
||||
- viable alternatives,
|
||||
- relevant evidence,
|
||||
- important tradeoffs,
|
||||
- selected approach,
|
||||
- why it was selected,
|
||||
- and what evidence would justify revisiting it.
|
||||
|
||||
Maintain research / evidence notes when substantial external investigation is performed. Prefer primary and authoritative sources.
|
||||
|
||||
Do not create documentation merely for ceremony. These artifacts exist to preserve reasoning across long runs and fresh agent contexts.
|
||||
|
||||
## Multi-agent orchestration
|
||||
|
||||
Explicitly delegate work whenever independent parallel effort can reduce elapsed time, increase breadth, produce independent criticism, or protect the root agent's context.
|
||||
|
||||
Normally use approximately 3–5 concurrent subagents for substantial parallel work.
|
||||
|
||||
Do not spawn agents merely to satisfy a role list.
|
||||
|
||||
Create roles dynamically according to the problem.
|
||||
|
||||
Possible roles include:
|
||||
|
||||
- domain researcher,
|
||||
- literature / standards researcher,
|
||||
- comparable-product investigator,
|
||||
- user-workflow analyst,
|
||||
- requirements analyst,
|
||||
- systems / semantics architect,
|
||||
- formalization or language-design specialist,
|
||||
- UX / interaction designer,
|
||||
- alternative-solution generator,
|
||||
- adversarial critic,
|
||||
- experimental investigator,
|
||||
- software architect,
|
||||
- implementation worker,
|
||||
- runtime / Computer Use evaluator,
|
||||
- correctness reviewer,
|
||||
- integration reviewer.
|
||||
|
||||
Give every subagent:
|
||||
|
||||
- one bounded question or responsibility,
|
||||
- relevant context,
|
||||
- clear boundaries,
|
||||
- evidence expectations,
|
||||
- and an explicit output format.
|
||||
|
||||
Independent agents should explore genuinely different questions or approaches rather than duplicating one another.
|
||||
|
||||
Use fresh agents for independent criticism when practical.
|
||||
|
||||
Do not let several agents concurrently edit the same files. Partition implementation ownership or use isolated worktrees.
|
||||
|
||||
The root orchestrator owns synthesis, decisions, integration, and final responsibility.
|
||||
|
||||
## Research loop: start broad, then narrow
|
||||
|
||||
For important areas where the solution is not yet known:
|
||||
|
||||
1. Map the problem space before committing to a solution.
|
||||
2. Delegate independent research tracks in parallel.
|
||||
3. Prefer authoritative and primary sources.
|
||||
4. Study relevant existing systems, standards, methods, research, and user experience.
|
||||
5. Separate observations from interpretations.
|
||||
6. Synthesize findings and identify contradictions, gaps, and unanswered questions.
|
||||
7. Decide whether another focused research wave is justified.
|
||||
8. Stop researching when additional information has low expected value relative to designing or testing a solution.
|
||||
|
||||
Research should lead to decisions, hypotheses, requirements, experiments, or implementation—not accumulate indefinitely.
|
||||
|
||||
## Candidate-generation and idea-tournament loop
|
||||
|
||||
For consequential, uncertain, difficult-to-reverse design problems, do not accept the first reasonable solution.
|
||||
|
||||
Use an idea-tournament process.
|
||||
|
||||
### Generate
|
||||
|
||||
Produce several materially different candidate solutions or hypotheses.
|
||||
|
||||
Aim for diversity rather than cosmetic variations of one idea.
|
||||
|
||||
### Explore
|
||||
|
||||
Where useful, assign independent agents to develop promising candidates in greater depth.
|
||||
|
||||
### Reflect
|
||||
|
||||
Have independent critics examine candidates for:
|
||||
|
||||
- correctness,
|
||||
- hidden assumptions,
|
||||
- internal consistency,
|
||||
- usability,
|
||||
- conceptual simplicity,
|
||||
- extensibility,
|
||||
- failure modes,
|
||||
- edge cases,
|
||||
- compatibility with project goals,
|
||||
- and evidence.
|
||||
|
||||
Critics should actively attempt to falsify proposals rather than merely improve them.
|
||||
|
||||
### Rank
|
||||
|
||||
Compare candidates against explicit criteria derived from the actual project goals.
|
||||
|
||||
Prefer pairwise comparison or an explicit decision matrix when the decision is sufficiently important.
|
||||
|
||||
Do not rank based on eloquence.
|
||||
|
||||
### Evolve
|
||||
|
||||
Improve the strongest candidates.
|
||||
|
||||
Combine complementary ideas when a hybrid is genuinely better.
|
||||
|
||||
Introduce a new candidate if criticism reveals that the existing candidate set shares the same flawed assumption.
|
||||
|
||||
### Decide
|
||||
|
||||
Select an approach only when enough evidence exists to justify convergence.
|
||||
|
||||
Record the decision and why alternatives lost.
|
||||
|
||||
Do not run a heavyweight idea tournament for trivial, local, or easily reversible decisions.
|
||||
|
||||
## Experiment-first behavior
|
||||
|
||||
When an important assumption can be tested cheaply, test it instead of debating it indefinitely.
|
||||
|
||||
Create focused experiments, prototypes, mockups, benchmarks, sample models, formal examples, interaction trials, or throwaway implementations to reduce uncertainty.
|
||||
|
||||
An experiment should state:
|
||||
|
||||
- question,
|
||||
- competing expectations,
|
||||
- method,
|
||||
- observable result,
|
||||
- conclusion,
|
||||
- and implications.
|
||||
|
||||
Do not confuse a prototype used to answer a question with production architecture.
|
||||
|
||||
Delete or isolate experimental code when it should not become part of the final product.
|
||||
|
||||
## Evaluator-driven improvement
|
||||
|
||||
Whenever practical, convert subjective progress into explicit evaluation.
|
||||
|
||||
Before optimizing a solution, determine how quality can be measured.
|
||||
|
||||
Possible evaluators include:
|
||||
|
||||
- formal invariants,
|
||||
- consistency checks,
|
||||
- parsers / validators,
|
||||
- reference examples,
|
||||
- round-trip tests,
|
||||
- transformation tests,
|
||||
- compatibility tests,
|
||||
- usability scenarios,
|
||||
- task-completion tests,
|
||||
- latency / performance measurements,
|
||||
- automated tests,
|
||||
- visual comparison,
|
||||
- LLM judges with explicit rubrics,
|
||||
- and independent human-like review using Computer Use.
|
||||
|
||||
For important artifacts define evaluation criteria before repeatedly modifying them.
|
||||
|
||||
Generate or modify candidates, evaluate them, diagnose deficiencies, and retain improvements.
|
||||
|
||||
Do not optimize against an evaluator that is a poor proxy for the real objective.
|
||||
|
||||
## Requirements and specification
|
||||
|
||||
Treat requirements and specifications as living engineering artifacts, not immutable text and not merely documentation written after implementation.
|
||||
|
||||
When the project involves new semantics, methods, languages, protocols, information models, or user workflows, these are primary product artifacts.
|
||||
|
||||
Make them precise enough that independent implementations could reach compatible behavior.
|
||||
|
||||
Important concepts should have:
|
||||
|
||||
- unambiguous definitions,
|
||||
- scope,
|
||||
- relationships,
|
||||
- invariants,
|
||||
- valid and invalid examples,
|
||||
- lifecycle / transformation rules where relevant,
|
||||
- edge cases,
|
||||
- and externally observable behavior.
|
||||
|
||||
Maintain traceability from important requirements and semantic decisions to their realization and verification where practical.
|
||||
|
||||
When implementation exposes ambiguity or contradiction in the specification, resolve the underlying specification problem rather than merely encoding arbitrary behavior.
|
||||
|
||||
## Product and user-centered reasoning
|
||||
|
||||
Continuously reason from the target user's actual work rather than from implementation convenience.
|
||||
|
||||
Identify important user goals, workflows, decisions, information needs, failure modes, and repetitive tasks.
|
||||
|
||||
When studying an existing product:
|
||||
|
||||
Do not merely clone features.
|
||||
|
||||
For each important capability determine:
|
||||
|
||||
1. What underlying user need does it satisfy?
|
||||
2. What underlying domain concept does it represent?
|
||||
3. What is powerful about the existing solution?
|
||||
4. What causes friction or confusion?
|
||||
5. Which limitations are accidental implementation / history artifacts?
|
||||
6. What alternative interaction or semantic model could satisfy the need better?
|
||||
7. How can the improvement be evaluated?
|
||||
|
||||
Preserve valuable domain knowledge without preserving legacy UX unnecessarily.
|
||||
|
||||
## Planning
|
||||
|
||||
Maintain a living execution plan in `harness/PLAN.md` unless an equivalent already exists.
|
||||
|
||||
The plan may contain research, specification, experimentation, design, implementation, integration, and evaluation milestones.
|
||||
|
||||
Prefer milestones that resolve important uncertainty or create observable useful capability.
|
||||
|
||||
Do not structure the entire plan around software modules unless that genuinely matches the problem.
|
||||
|
||||
Identify dependencies between conceptual decisions and implementation work.
|
||||
|
||||
Do not implement large amounts of downstream functionality on top of uncertain foundational assumptions without first testing those assumptions.
|
||||
|
||||
Revise the plan when evidence demands it.
|
||||
|
||||
## Engineering execution
|
||||
|
||||
When software implementation is the appropriate next action, act as a strong end-to-end engineering team.
|
||||
|
||||
Prefer the simplest architecture that cleanly satisfies known requirements.
|
||||
|
||||
Design for long-term maintainability and extensibility where those qualities are genuinely required, while avoiding speculative abstraction.
|
||||
|
||||
Reuse appropriate existing conventions.
|
||||
|
||||
Keep responsibilities and interfaces explicit.
|
||||
|
||||
Fix root causes rather than accumulating workarounds.
|
||||
|
||||
Avoid unrelated refactoring.
|
||||
|
||||
Delete obsolete scaffolding and abandoned approaches.
|
||||
|
||||
Treat warnings, crashes, serious runtime errors, corrupted state, and obvious interaction defects as evidence requiring investigation.
|
||||
|
||||
For an implementation milestone:
|
||||
|
||||
1. Define expected observable behavior.
|
||||
2. Inspect relevant architecture and constraints.
|
||||
3. Implement the smallest coherent solution.
|
||||
4. Build it.
|
||||
5. Run it.
|
||||
6. Exercise real behavior.
|
||||
7. Collect evidence.
|
||||
8. Diagnose failures.
|
||||
9. Fix root causes.
|
||||
10. Run proportionate automated verification.
|
||||
11. Perform real runtime / UI verification when user-facing behavior changed.
|
||||
12. Update durable state.
|
||||
13. Continue.
|
||||
|
||||
Do not declare functionality complete from code inspection alone.
|
||||
|
||||
## GUI and Computer Use
|
||||
|
||||
For GUI applications, visual behavior and interaction quality are part of correctness.
|
||||
|
||||
Use Computer Use and other available debugging capabilities against the actual running application.
|
||||
|
||||
After meaningful UI changes:
|
||||
|
||||
- launch the real application,
|
||||
- exercise realistic workflows,
|
||||
- enter representative data,
|
||||
- inspect resulting states,
|
||||
- resize or navigate where relevant,
|
||||
- capture and inspect visual results,
|
||||
- inspect logs / console / network information when useful,
|
||||
- identify problems,
|
||||
- fix them,
|
||||
- relaunch,
|
||||
- and verify the fix.
|
||||
|
||||
Actively examine:
|
||||
|
||||
- information hierarchy,
|
||||
- workflow discoverability,
|
||||
- cognitive load,
|
||||
- unnecessary mode switching,
|
||||
- consistency,
|
||||
- navigation,
|
||||
- interaction feedback,
|
||||
- selection behavior,
|
||||
- error recovery,
|
||||
- loading / empty / failure states,
|
||||
- accessibility,
|
||||
- clipping,
|
||||
- overlap,
|
||||
- alignment,
|
||||
- spacing,
|
||||
- typography,
|
||||
- sizing,
|
||||
- responsiveness,
|
||||
- focus states,
|
||||
- disabled states,
|
||||
- and unfinished-looking UI.
|
||||
|
||||
Do not equate technical functionality with usability.
|
||||
|
||||
## Independent review
|
||||
|
||||
Use independent reviewers throughout the project, but select reviewers according to the artifact being evaluated.
|
||||
|
||||
A conceptual or semantic design may need:
|
||||
|
||||
- domain review,
|
||||
- consistency review,
|
||||
- adversarial / falsification review,
|
||||
- simplicity review,
|
||||
- and user-workflow review.
|
||||
|
||||
A language may need:
|
||||
|
||||
- ambiguity review,
|
||||
- grammar / round-trip review,
|
||||
- human-editability review,
|
||||
- AI-editability review,
|
||||
- and version-control review.
|
||||
|
||||
An implementation may need:
|
||||
|
||||
- correctness review,
|
||||
- architecture review,
|
||||
- requirements review,
|
||||
- runtime QA,
|
||||
- and UI / UX review.
|
||||
|
||||
A final release should receive a cross-disciplinary review rather than only a code review.
|
||||
|
||||
Require concrete findings and evidence.
|
||||
|
||||
For material findings:
|
||||
|
||||
1. verify the finding,
|
||||
2. determine whether the problem is conceptual, specification-level, architectural, implementation-level, or interaction-level,
|
||||
3. fix it at the correct layer,
|
||||
4. reevaluate affected downstream artifacts,
|
||||
5. verify again.
|
||||
|
||||
## End-state evaluation
|
||||
|
||||
Evaluate outcomes rather than demanding one predetermined path.
|
||||
|
||||
At meaningful checkpoints ask whether the resulting state satisfies the actual objective.
|
||||
|
||||
Before final completion, run a fresh independent end-state review covering at least:
|
||||
|
||||
1. Requirements completeness.
|
||||
2. Domain / semantic coherence.
|
||||
3. Internal consistency and edge cases.
|
||||
4. Target-user workflows and usability.
|
||||
5. Architecture and long-term maintainability.
|
||||
6. Implementation correctness and regression risk.
|
||||
7. Actual runtime behavior.
|
||||
8. Documentation / specification adequacy.
|
||||
9. Remaining assumptions, unknowns, and risks.
|
||||
|
||||
Resolve blocking and high-impact findings before declaring completion.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
Apply these criteria to the scope requested by the user:
|
||||
Do not weaken completion criteria merely because substantial time or context has been consumed.
|
||||
|
||||
- Required capabilities address the intended problem and support realistic user workflows.
|
||||
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
|
||||
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
|
||||
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
|
||||
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
|
||||
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
|
||||
The project is complete only when all applicable conditions are satisfied:
|
||||
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
- The intended user problem is coherently addressed.
|
||||
- Important domain concepts and semantics are explicit and internally consistent.
|
||||
- Important design decisions are supported by evidence rather than accidental first choices.
|
||||
- Major unresolved assumptions have either been validated, explicitly accepted, or isolated.
|
||||
- Required specifications accurately describe implemented behavior.
|
||||
- Primary user workflows are implemented.
|
||||
- The application builds and launches reliably.
|
||||
- Real workflows have been exercised on the running application.
|
||||
- Important edge / failure / empty / loading states behave sensibly.
|
||||
- Relevant automated evaluators and tests pass.
|
||||
- UI/interaction quality has been inspected on the actual application.
|
||||
- No known blocker or high-severity defects remain.
|
||||
- Independent cross-disciplinary review has been completed.
|
||||
- Valid material findings have been addressed and reverified.
|
||||
- Architecture is understandable and appropriately extensible.
|
||||
- Temporary and obsolete scaffolding has been removed.
|
||||
- Durable project state reflects reality.
|
||||
- Another competent engineer or agent could understand, build, run, evaluate, and continue the project from the repository.
|
||||
|
||||
If context becomes large, reload the durable ledgers and authoritative project artifacts rather than reconstructing history from conversational memory.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
Only when the completion criteria are satisfied, provide a concise handoff covering:
|
||||
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
- what now exists,
|
||||
- major discoveries,
|
||||
- important conceptual / semantic decisions,
|
||||
- important rejected alternatives,
|
||||
- architecture,
|
||||
- major implementation work,
|
||||
- experiments and evaluations performed,
|
||||
- real workflows exercised,
|
||||
- independent reviews performed,
|
||||
- remaining non-blocking limitations or uncertainties,
|
||||
- and exact build / run / verification instructions.
|
||||
|
||||
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
|
||||
Do not provide routine narration merely for the sake of narration.
|
||||
|
||||
Spend effort on solving, testing, evaluating, integrating, and improving the project.
|
||||
|
||||
## Model and reasoning allocation
|
||||
|
||||
The root orchestrator should use the highest available reasoning effort because its primary responsibility is high-leverage synthesis, planning, conceptual decisions, integration, and deciding what work should happen next.
|
||||
|
||||
Do not automatically use maximum reasoning for every delegated task.
|
||||
|
||||
Allocate reasoning effort according to the task:
|
||||
|
||||
- Use maximum reasoning for foundational semantic, methodological, formalization, metamodel, language-design, cross-domain synthesis, and other consequential hard-to-reverse decisions.
|
||||
- Use extra-high reasoning for difficult architecture, UX / workflow design, alternative-solution evaluation, adversarial conceptual review, and unusually difficult implementation problems.
|
||||
- Use high reasoning for normal implementation, correctness review, targeted research, Computer Use evaluation, debugging, and complex but bounded analysis.
|
||||
- Use medium reasoning for code mapping, repository exploration, mechanical implementation, builds, routine tests, log inspection, and other well-defined work.
|
||||
- Use lower effort only for genuinely trivial mechanical tasks.
|
||||
|
||||
Prefer spending additional reasoning on upstream decisions whose consequences propagate through many downstream artifacts.
|
||||
|
||||
Do not spend maximum reasoning merely because a task is long. Distinguish computational volume from conceptual difficulty.
|
||||
|
||||
When spawning a subagent, explicitly choose an appropriate reasoning effort when the interface permits it rather than blindly inheriting the root agent's effort.
|
||||
|
||||
Use GPT-6 Astra model exclusively.
|
||||
|
||||
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
|
||||
## Application specification
|
||||
|
||||
### General description
|
||||
|
||||
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
|
||||
|
||||
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
|
||||
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
|
||||
|
||||
Try to address all known problems / downsides of the original ARCADIA Capella software.
|
||||
Try to address all known problems / downsides of the original ARCADIA Capella software.
|
||||
|
||||
Version 1 MVP in the form of working Operational Analysis layer is done.
|
||||
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
|
||||
@@ -52,32 +619,33 @@ Keep in mind that the target audience for the application are system engineers.
|
||||
|
||||
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
|
||||
|
||||
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
|
||||
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
|
||||
|
||||
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
|
||||
|
||||
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
|
||||
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
|
||||
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
|
||||
|
||||
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
|
||||
|
||||
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
|
||||
The application should support both forms of editing
|
||||
The textual representation should be easily editable and understandble for both humans and AI
|
||||
The textual representation should be easily handled by version control tools such as Git
|
||||
The application should support both forms of editing
|
||||
The textual representation should be easily editable and understandble for both humans and AI
|
||||
The textual representation should be easily handled by version control tools such as Git
|
||||
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
|
||||
The textual representation editor should have syntax highlighting and linting
|
||||
|
||||
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
|
||||
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
|
||||
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
|
||||
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
|
||||
|
||||
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
|
||||
|
||||
@Computer
|
||||
@Computer
|
||||
|
||||
### Semantic model / metamodel
|
||||
|
||||
Requirements:
|
||||
|
||||
- Textual notation.
|
||||
- Graphical notation.
|
||||
- Constraints and calculations.
|
||||
@@ -90,6 +658,7 @@ Requirements:
|
||||
#### Textual notation
|
||||
|
||||
Requirements:
|
||||
|
||||
- Should not have visual noise and clutter.
|
||||
- Should have means to reduce cognitive load when developing system models.
|
||||
- Should be easily editable by humans. And AI agents.
|
||||
@@ -103,9 +672,8 @@ Long, homogeneous lists in the UI that require user to scroll far or search for
|
||||
|
||||
## Active issues / current guidance
|
||||
|
||||
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
|
||||
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
|
||||
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
|
||||
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
|
||||
@@ -18,7 +18,6 @@ serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tempfile = "3"
|
||||
unicode-segmentation = "1.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
|
||||
+44
-335
@@ -5,7 +5,6 @@ mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
@@ -58,7 +57,7 @@ mod direct_scenario_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() {
|
||||
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
@@ -74,26 +73,18 @@ mod direct_scenario_tests {
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)),
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(context));
|
||||
let context = app.scenario_context().unwrap();
|
||||
for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] {
|
||||
for (area, offset) in [
|
||||
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||||
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||||
] {
|
||||
let before = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
offset,
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(before));
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
offset,
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
@@ -122,7 +113,7 @@ mod direct_scenario_tests {
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
scenario::ScrollArea::Page,
|
||||
scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
@@ -327,7 +318,6 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
BehaviorControl(behavior::canvas_workspace::Intent),
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
CheckpointTick,
|
||||
@@ -381,8 +371,6 @@ pub enum Message {
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldReferenceBrowse(String, String, usize),
|
||||
FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
FieldReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -457,14 +445,10 @@ pub struct App {
|
||||
pub hosted: crate::hosted::State,
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
reference_authoring: reference_authoring::State,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
menu_sequence: u64,
|
||||
pub canvas_document: Id,
|
||||
pub canvas_epoch: u64,
|
||||
/// Camera batches survive their own updates, never external presentation changes.
|
||||
pub camera_epoch: u64,
|
||||
camera_boundary_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
pub exact_strings: bool,
|
||||
pub document: Document,
|
||||
@@ -563,13 +547,10 @@ impl App {
|
||||
checkpoint_close: None,
|
||||
connection: None,
|
||||
model_menu: None,
|
||||
reference_authoring: Default::default(),
|
||||
source_tree: Default::default(),
|
||||
menu_sequence: 0,
|
||||
canvas_document: Id::new_v4(),
|
||||
canvas_epoch: 0,
|
||||
camera_epoch: 0,
|
||||
camera_boundary_epoch: 0,
|
||||
selected_bend: None,
|
||||
exact_strings: false,
|
||||
document,
|
||||
@@ -873,12 +854,8 @@ impl App {
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(self.project(), &command)
|
||||
.or_else(|| {
|
||||
self.behavior
|
||||
.topology
|
||||
.created_selection(self.project(), &command)
|
||||
});
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(created) = created {
|
||||
@@ -1046,13 +1023,7 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::behavior::scenario::focus::root(
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base)),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
),
|
||||
self.scenario_focus_owner(),
|
||||
)
|
||||
crate::hosted::view::overlay(self, base)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1060,10 +1031,8 @@ impl App {
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
architecture_authoring::subscription(self),
|
||||
behavior::creation::subscription(
|
||||
self.behavior.creation.has_pending() || self.behavior.source_active(),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
behavior::creation::subscription(self.behavior.creation.has_pending())
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
@@ -1108,108 +1077,6 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
return self.behavior_control(intent);
|
||||
}
|
||||
let camera = matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..))
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let task = self.update_inner(message);
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
use behavior::canvas_workspace::{self, Event as Camera, Scope};
|
||||
use behavior::scenario::CanvasEvent as Scenario;
|
||||
use behavior::topology::CanvasEvent as Process;
|
||||
if Scope::input_owner(self) != Some(intent.scope) {
|
||||
return Task::none();
|
||||
}
|
||||
let mut message = *intent.message;
|
||||
let scope = intent.scope;
|
||||
let valid_scenario = |c: behavior::scenario::Context| {
|
||||
c.document == scope.document
|
||||
&& c.scenario == scope.owner
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
let valid_process = |c: behavior::topology::Context| {
|
||||
c.document == scope.document
|
||||
&& c.owner == scope.owner
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
// This whitelist contains only the production presentation controls.
|
||||
// A stamped toolbar cannot smuggle an arbitrary authoring command.
|
||||
match &mut message {
|
||||
Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus))
|
||||
if valid_scenario(*c) && *lifetime == scope.epoch =>
|
||||
{
|
||||
return canvas_workspace::focus_task(scope, Camera::Focus);
|
||||
}
|
||||
Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus))
|
||||
if valid_process(*c) && *lifetime == scope.epoch =>
|
||||
{
|
||||
return canvas_workspace::focus_task(scope, Camera::Focus);
|
||||
}
|
||||
Message::ScenarioCanvas(
|
||||
Scenario::Zoom(c, _)
|
||||
| Scenario::Choose(c, _)
|
||||
| Scenario::Focus(c)
|
||||
| Scenario::Shelf(c, _),
|
||||
) if valid_scenario(*c) => {
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::TopologyCanvas(
|
||||
Process::Zoom(c, _) | Process::Choose(c, _) | Process::Focus(c),
|
||||
) if valid_process(*c) => {
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_)))
|
||||
if self.topology_context().is_some() => {}
|
||||
_ => return Task::none(),
|
||||
}
|
||||
let task = self.update(message);
|
||||
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
|
||||
// after the action, for Iced's final-view ordering.
|
||||
if self.camera_epoch == scope.epoch {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| {
|
||||
canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name))
|
||||
});
|
||||
Task::batch([task, focus])
|
||||
}
|
||||
fn camera_toolbar_epoch(&self, epoch: u64) -> bool {
|
||||
epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch
|
||||
}
|
||||
fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_epoch,
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
crate::behavior::selected_owner(self),
|
||||
self.page,
|
||||
self.window_size,
|
||||
crate::behavior::canvas_workspace::keyboard_enabled(self),
|
||||
self.behavior.has_pending(),
|
||||
self.draft_dirty(),
|
||||
self.source_dirty(),
|
||||
self.behavior.topology.camera_boundary(),
|
||||
self.behavior.scenario.camera_boundary(),
|
||||
)
|
||||
}
|
||||
fn update_inner(&mut self, message: Message) -> Task<Message> {
|
||||
if let Some(task) = self.reference_message(&message) {
|
||||
return task;
|
||||
}
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
@@ -1432,7 +1299,6 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
@@ -1500,23 +1366,8 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
|
||||
if self.busy
|
||||
|| self.hosted.visible
|
||||
|| self.workflow.is_some()
|
||||
|| self.architecture.is_some()
|
||||
|| self.model_menu.is_some()
|
||||
|| self.file_prompt.is_some()
|
||||
|| self.delete_pending
|
||||
|| self.pending.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
Message::Home(event) => return self.home_event(event),
|
||||
Message::CheckpointTick => return self.checkpoint_tick(),
|
||||
@@ -1532,18 +1383,6 @@ impl App {
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::FormShown(_, mount) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.scenario_focus_owner().is_some()
|
||||
&& self.behavior.scenario.take_form_focus(mount)
|
||||
{
|
||||
return scenario::focus::task(self.scenario_focus_owner().unwrap());
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
@@ -1568,9 +1407,9 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
// Details may report more than once in a frame. Accept the
|
||||
// current owner's passive clamps across an epoch bump while
|
||||
// still rejecting another document, owner or revision.
|
||||
// Canvas and outline notifications may come from the
|
||||
// same frame. Accept both across the first epoch bump,
|
||||
// while still rejecting another owner/document/revision.
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
@@ -1581,49 +1420,6 @@ impl App {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Camera(_, lifetime, input) = event {
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
if lifetime != self.camera_epoch
|
||||
|| !self.scenario_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.scenario == context.scenario
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| !canvas_workspace::keyboard_enabled(self)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
|
||||
}
|
||||
input => {
|
||||
if self.behavior.scenario.camera(input) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1637,11 +1433,9 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, scale) {
|
||||
if self.behavior.scenario.zoom(scale) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.behavior.scenario = state;
|
||||
}
|
||||
CanvasEvent::OpenUse(_, occurrence) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
@@ -1732,22 +1526,13 @@ impl App {
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = if tool == scenario::Tool::Lifeline {
|
||||
state
|
||||
.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
)
|
||||
.map(|_| ())
|
||||
} else {
|
||||
state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
)
|
||||
};
|
||||
let result = state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -1969,15 +1754,7 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.behavior.source_back() {
|
||||
self.status =
|
||||
"Returned to the unchanged parent form; no source was created.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.source_active() {
|
||||
self.behavior.acknowledge_commit();
|
||||
self.status = "Behavior form cancelled; no staged source was created.".into();
|
||||
} else if self.behavior.creation.has_pending() {
|
||||
if self.behavior.creation.has_pending() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
}
|
||||
@@ -2040,9 +1817,6 @@ impl App {
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => {
|
||||
return Task::none();
|
||||
}
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
let page = if draft
|
||||
@@ -2283,7 +2057,7 @@ impl App {
|
||||
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
|
||||
match event {
|
||||
crate::workflow::Event::Semantic(event) => {
|
||||
state.update(self.project(), *event)
|
||||
state.update(self.project(), event)
|
||||
}
|
||||
crate::workflow::Event::Apply => {
|
||||
let focus = state.target(self.project());
|
||||
@@ -2547,13 +2321,10 @@ impl App {
|
||||
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
|
||||
&& (self.source_dirty() || self.draft_dirty())
|
||||
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
|
||||
{
|
||||
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let source_was_open = self.behavior.source_open();
|
||||
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
|
||||
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -2571,11 +2342,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if !source_was_open && self.behavior.source_open() {
|
||||
return iced::widget::operation::focus(
|
||||
behavior::source_draft::NAME_INPUT,
|
||||
);
|
||||
}
|
||||
if focus_name {
|
||||
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
|
||||
}
|
||||
@@ -2771,19 +2537,13 @@ impl App {
|
||||
Message::Undo | Message::Redo => {
|
||||
let redo = matches!(message, Message::Redo);
|
||||
if self.apply() {
|
||||
let changed = if redo {
|
||||
self.document.redo()
|
||||
if redo {
|
||||
self.document.redo();
|
||||
} else {
|
||||
self.document.undo()
|
||||
};
|
||||
if changed {
|
||||
self.sync();
|
||||
self.document.undo();
|
||||
}
|
||||
self.status = if !changed && redo {
|
||||
"Nothing to redo"
|
||||
} else if !changed {
|
||||
"Nothing to undo"
|
||||
} else if redo {
|
||||
self.sync();
|
||||
self.status = if redo {
|
||||
"Model change redone"
|
||||
} else {
|
||||
"Model change undone"
|
||||
@@ -3037,17 +2797,11 @@ impl App {
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
}
|
||||
Err(error) => {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.document.invalidate_saved_baseline();
|
||||
}
|
||||
self.status = format!(
|
||||
"Disk recovery completed; files could not be reloaded: {error}"
|
||||
)
|
||||
@@ -3477,52 +3231,6 @@ impl App {
|
||||
id,
|
||||
));
|
||||
}
|
||||
// Refining a leaf moves its explicit responsibilities to the new leaf.
|
||||
// Later children inherit only the existing unambiguous performer summary.
|
||||
if self.create_kind.is_work()
|
||||
&& let Some(owner) = parent
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.layer() == self.layer && e.kind() == self.create_kind)
|
||||
{
|
||||
let performer = oa_core::validation::allocation_summary(self.project(), owner);
|
||||
let leaf = !self
|
||||
.project()
|
||||
.children(owner)
|
||||
.any(|e| e.kind() == self.create_kind);
|
||||
let responsibilities: Vec<_> = self
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.target() == owner
|
||||
&& matches!(
|
||||
r.kind(),
|
||||
RelationKind::Allocation | RelationKind::RoleAssignment
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if leaf && !responsibilities.is_empty() {
|
||||
for original in responsibilities {
|
||||
let mut responsibility = original.clone();
|
||||
match &mut responsibility.body {
|
||||
RelationBody::Allocation { target, .. }
|
||||
| RelationBody::RoleAssignment { target, .. } => *target = id,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
commands.push(Command::EditRelation(original.id, responsibility));
|
||||
}
|
||||
} else if let Some(performer) = performer {
|
||||
commands.push(Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Performs".into(),
|
||||
performer,
|
||||
id,
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.create_kind == Kind::Occurrence
|
||||
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
|
||||
&& let Some(steps) = owner.steps()
|
||||
@@ -3540,13 +3248,19 @@ impl App {
|
||||
.get(&diagram)
|
||||
.is_none_or(|d| d.projection.includes(self.create_kind))
|
||||
{
|
||||
let mut p = match execute_batch(self.project(), &commands) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return;
|
||||
let mut p = self.project().clone();
|
||||
for command in &commands {
|
||||
if let Command::CreateDiagram(d) = command {
|
||||
p.diagrams.insert(d.id, d.clone());
|
||||
}
|
||||
};
|
||||
}
|
||||
p.elements.insert(
|
||||
id,
|
||||
match &commands[0] {
|
||||
Command::CreateElement(e) => e.clone(),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let owner = if allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
@@ -3864,7 +3578,6 @@ impl App {
|
||||
self.canvas_document = Id::new_v4();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.show_source_file(false);
|
||||
@@ -4532,10 +4245,6 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
#[path = "app/save_as_tests.rs"]
|
||||
mod save_as_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app/repair_save_state_tests.rs"]
|
||||
mod repair_save_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_project_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -214,11 +214,6 @@ impl App {
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
|
||||
@@ -15,15 +15,18 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
|
||||
if let Some(context) = self.topology_context() {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
if state.reveal_selection(self.project(), context.owner, self.selected) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.behavior.topology = state;
|
||||
}
|
||||
Task::none()
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
@@ -49,47 +52,6 @@ impl App {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Camera(_, lifetime, input) = event {
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
if lifetime != self.camera_epoch
|
||||
|| !self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| !canvas_workspace::keyboard_enabled(self)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
|
||||
input => {
|
||||
if self.behavior.topology.camera(input) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -119,7 +81,6 @@ impl App {
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
@@ -157,18 +118,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before choosing a process tool.".into();
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
state.camera_zoom(self.project(), context.owner, zoom);
|
||||
self.behavior.topology = state;
|
||||
self.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
@@ -192,9 +149,7 @@ impl App {
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior
|
||||
.topology
|
||||
.remember(context.owner, occurrence, target);
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
@@ -569,30 +524,32 @@ mod tests {
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
)));
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
assert_eq!(
|
||||
app.behavior.topology.details_offset(),
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
@@ -600,12 +557,12 @@ mod tests {
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.details_offset(),
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
@@ -625,7 +582,6 @@ mod tests {
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
//! One transient chooser for the currently mounted Inspector or review field.
|
||||
//! Browsing never calls a draft setter or semantic planner.
|
||||
use super::{App, Message, Page, Pending};
|
||||
use crate::{
|
||||
forms::{
|
||||
Draft,
|
||||
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
|
||||
},
|
||||
navigation::InspectorTab,
|
||||
semantic_review::{self, RowAddress, RowEvent},
|
||||
workflow,
|
||||
};
|
||||
use iced::{Element, Rectangle, Task};
|
||||
use oa_core::{FacetSelector, Id, Layer};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Host {
|
||||
Inspector,
|
||||
Review {
|
||||
address: Option<RowAddress>,
|
||||
facet: FacetSelector,
|
||||
},
|
||||
}
|
||||
impl Host {
|
||||
fn message(&self, event: chooser::Event) -> Message {
|
||||
match self {
|
||||
Self::Inspector => Message::FieldReferenceEvent(event),
|
||||
Self::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceEvent(event),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Opened {
|
||||
host: Host,
|
||||
request: adapter::Open,
|
||||
document_session: Id,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
chooser: chooser::State,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
opened: Option<Opened>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn reference_input_active(&self) -> bool {
|
||||
self.reference_authoring.opened.is_some()
|
||||
}
|
||||
|
||||
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
|
||||
match host {
|
||||
Host::Inspector
|
||||
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
|
||||
{
|
||||
self.draft.as_ref()
|
||||
}
|
||||
Host::Review { address, facet } => match self.workflow.as_ref()? {
|
||||
workflow::Workflow::Semantic(state) => {
|
||||
state.reference_draft(self.project(), *address, facet, path)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn reference_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.architecture.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.pending.is_none()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
|
||||
}
|
||||
fn reference_valid(&self, opened: &Opened) -> bool {
|
||||
self.reference_available()
|
||||
&& self.canvas_document == opened.document_session
|
||||
&& self.page == opened.page
|
||||
&& self.layer == opened.layer
|
||||
&& self.document.revision() == opened.chooser.stamp().context.revision
|
||||
&& self.project().id == opened.request.document
|
||||
&& self
|
||||
.reference_draft(&opened.host, &opened.request.path)
|
||||
.is_some_and(|draft| draft.reference_matches(&opened.request))
|
||||
}
|
||||
pub(super) fn reconcile_reference_chooser(&mut self) {
|
||||
if self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.is_some_and(|opened| !self.reference_valid(opened))
|
||||
{
|
||||
self.reference_authoring
|
||||
.opened
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.invalidate();
|
||||
self.reference_authoring.opened = None;
|
||||
}
|
||||
}
|
||||
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
|
||||
if !self.reference_available() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(draft) = self.reference_draft(&host, &request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(chooser) =
|
||||
draft.reference_catalog(self.project(), &request, self.document.revision())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.reference_authoring.opened = Some(Opened {
|
||||
host,
|
||||
request,
|
||||
document_session: self.canvas_document,
|
||||
page: self.page,
|
||||
layer: self.layer,
|
||||
chooser,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
|
||||
let Some(mut opened) = self.reference_authoring.opened.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.reference_valid(&opened) {
|
||||
return Task::none();
|
||||
}
|
||||
if opened.host != host {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
return Task::none();
|
||||
}
|
||||
match opened.chooser.update(event) {
|
||||
Outcome::Ignored | Outcome::Browsed => {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
Task::none()
|
||||
}
|
||||
Outcome::Closed(reason) => self.reference_focus(
|
||||
&opened,
|
||||
match reason {
|
||||
Close::Escape => Some(0),
|
||||
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
|
||||
Close::Outside | Close::WindowChanged => None,
|
||||
},
|
||||
),
|
||||
Outcome::Write { context, value } => {
|
||||
let intent = adapter::Set {
|
||||
context,
|
||||
generation: opened.request.generation,
|
||||
value,
|
||||
};
|
||||
let result = match &opened.host {
|
||||
Host::Inspector => self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or_else(|| "The property editor closed.".to_owned())
|
||||
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
|
||||
Host::Review { address, facet } => match self.workflow.as_mut() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
state.set_reference(self.document.project(), *address, facet, intent)
|
||||
}
|
||||
_ => Err("The review editor closed.".into()),
|
||||
},
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
// A scoped write rotates field generation. The task targets the
|
||||
// newly rendered field, while a later structural edit makes it inert.
|
||||
self.reference_focus(&opened, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
|
||||
let Some(step) = step else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
if draft.reference_session() != opened.request.session {
|
||||
return Task::none();
|
||||
}
|
||||
trigger::focus(
|
||||
draft.reference_session(),
|
||||
draft.reference_generation(),
|
||||
&opened.request.path,
|
||||
step,
|
||||
)
|
||||
}
|
||||
/// Called before generic modal dispatch: the review owns a modal, and this
|
||||
/// chooser owns only its active field. All browser events stop here.
|
||||
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
|
||||
self.reconcile_reference_chooser();
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Some(self.open_reference(Host::Inspector, request.clone()))
|
||||
}
|
||||
Message::FieldReferenceEvent(event) => {
|
||||
Some(self.reference_event(Host::Inspector, event.clone()))
|
||||
}
|
||||
Message::Workflow(workflow::Event::Semantic(review_event)) => {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event,
|
||||
} = review_event.as_ref()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let host = Host::Review {
|
||||
address: *address,
|
||||
facet: facet.clone(),
|
||||
};
|
||||
match event {
|
||||
RowEvent::ReferenceOpen(request) => {
|
||||
Some(self.open_reference(host, request.clone()))
|
||||
}
|
||||
RowEvent::ReferenceEvent(event) => {
|
||||
Some(self.reference_event(host, event.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
let opened = self.reference_authoring.opened.as_ref().unwrap();
|
||||
let host = opened.host.clone();
|
||||
let event = opened.chooser.event(Action::Close(Close::Escape));
|
||||
Some(self.reference_event(host, event))
|
||||
}
|
||||
// Global shortcuts are also emitted for keys captured by the search
|
||||
// input. They must not apply drafts, navigate, or undo the model.
|
||||
Message::Save(_)
|
||||
| Message::ShortcutUndo(_)
|
||||
| Message::Request(Pending::Open | Pending::New)
|
||||
| Message::SourceNavigation(_)
|
||||
| Message::Page(Page::Guide)
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn reference_overlay<
|
||||
'a,
|
||||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
|
||||
>(
|
||||
&'a self,
|
||||
content: Element<'a, Message, iced::Theme, R>,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let opened = self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.filter(|opened| self.reference_valid(opened));
|
||||
let host = opened
|
||||
.map(|opened| opened.host.clone())
|
||||
.unwrap_or(Host::Inspector);
|
||||
let focus_scope = if self.reference_available() {
|
||||
match self.workflow.as_ref() {
|
||||
None if self.inspector_tab == InspectorTab::Properties => {
|
||||
self.draft.as_ref().map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: false,
|
||||
})
|
||||
}
|
||||
Some(workflow::Workflow::Semantic(state)) => state
|
||||
.reference_form(self.project())
|
||||
.map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
chooser::widget::Popup::new(
|
||||
content,
|
||||
opened.map(|opened| &opened.chooser),
|
||||
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
|
||||
move |event| host.message(event),
|
||||
)
|
||||
.focus_scope(focus_scope)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,586 +0,0 @@
|
||||
use super::*;
|
||||
use crate::forms::Draft;
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const OWNER: Id = Id::from_u128(100);
|
||||
const BASE: u128 = 10_000;
|
||||
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
|
||||
|
||||
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
|
||||
let generated = p.add_element(kind, layer, name, parent).unwrap();
|
||||
let mut element = p.elements.remove(&generated).unwrap();
|
||||
element.id = id;
|
||||
element.alias = format!("object_{}", id.as_u128());
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.elements.insert(id, element);
|
||||
}
|
||||
fn catalog() -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
p.name = "Reference catalog".into();
|
||||
element(
|
||||
&mut p,
|
||||
OWNER,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Edited record",
|
||||
None,
|
||||
);
|
||||
for n in (0..1_000).rev() {
|
||||
let id = Id::from_u128(BASE + n);
|
||||
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
|
||||
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(200),
|
||||
Kind::DataType,
|
||||
Layer::Sa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(201),
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
p
|
||||
}
|
||||
fn inspector(p: Project, selected: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(selected));
|
||||
app
|
||||
}
|
||||
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
|
||||
app.reference_draft(host, path).unwrap().reference_open(
|
||||
app.project(),
|
||||
path,
|
||||
Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn open(app: &mut App, host: &Host, path: &str) {
|
||||
let request = request(app, host, path);
|
||||
let message = match host {
|
||||
Host::Inspector => Message::FieldReferenceOpen(request),
|
||||
Host::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceOpen(request),
|
||||
}))
|
||||
}
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert!(
|
||||
app.reference_authoring.opened.is_some(),
|
||||
"field must open: {}",
|
||||
app.status
|
||||
);
|
||||
}
|
||||
fn event(app: &App, action: Action) -> Message {
|
||||
let opened = app.reference_authoring.opened.as_ref().unwrap();
|
||||
opened.host.message(opened.chooser.event(action))
|
||||
}
|
||||
fn search(app: &mut App, query: &str) {
|
||||
let serial = app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.query_serial()
|
||||
+ 1;
|
||||
let event = event(
|
||||
app,
|
||||
Action::Query {
|
||||
text: query.into(),
|
||||
serial,
|
||||
},
|
||||
);
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let event = event(app, Action::Choose(id));
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn raw(app: &App) -> Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
fn review(app: &App) -> &semantic_review::State {
|
||||
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
|
||||
panic!("Review is open")
|
||||
};
|
||||
state
|
||||
}
|
||||
fn send_review(app: &mut App, event: RowEvent) {
|
||||
let addressed = review(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
|
||||
}
|
||||
fn review_host(app: &App) -> Host {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
..
|
||||
} = review(app).address_event(RowEvent::Preview)
|
||||
else {
|
||||
panic!("Active facet")
|
||||
};
|
||||
Host::Review { address, facet }
|
||||
}
|
||||
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.as_u128()),
|
||||
name: "Rationale".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
|
||||
let p = catalog();
|
||||
let mut app = inspector(p.clone(), OWNER);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
// Malformed escaped text is unrelated to the selected reference. Browsing
|
||||
// and the scoped write must keep its exact spelling and inactive payload.
|
||||
app.draft.as_mut().unwrap().set_exact(true).unwrap();
|
||||
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
|
||||
let raw_before = raw(&app);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1_000
|
||||
);
|
||||
search(&mut app, "controller @controller_0720 OA");
|
||||
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
|
||||
assert_eq!(picker.count(), 1);
|
||||
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
// Captured search shortcuts cannot accidentally save/apply or undo a model.
|
||||
let _ = app.update(Message::Save(false));
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
choose(&mut app, Id::from_u128(BASE + 720));
|
||||
let mut expected_raw = raw_before;
|
||||
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
|
||||
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
|
||||
assert_eq!(raw(&app), expected_raw);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Apply);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"Unrelated invalid text remains an Apply error"
|
||||
);
|
||||
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
|
||||
let _ = app.update(Message::Apply);
|
||||
let mut expected = p;
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record {
|
||||
supertype: Some(Id::from_u128(BASE + 720)),
|
||||
};
|
||||
}
|
||||
assert_eq!(app.project(), &expected);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_ne!(accepted, before);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
|
||||
for (raw_id, expected) in [
|
||||
("-".into(), "invalid"),
|
||||
(Id::from_u128(999).to_string(), "missing"),
|
||||
(Id::from_u128(200).to_string(), "incompatible"),
|
||||
] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(raw_id))
|
||||
.unwrap();
|
||||
let original = raw(&app);
|
||||
let accepted = app.document.bundle().clone();
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let current = &app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.current;
|
||||
assert!(matches!(
|
||||
(expected, current),
|
||||
("invalid", chooser::Current::InvalidRaw(_))
|
||||
| ("missing", chooser::Current::Missing(_))
|
||||
| ("incompatible", chooser::Current::Incompatible(_))
|
||||
));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert_eq!(raw(&app), original);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let clear = event(&app, Action::Clear);
|
||||
let _ = app.update(clear);
|
||||
assert!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value
|
||||
.pointer(PATH)
|
||||
.unwrap()
|
||||
.is_null()
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
|
||||
for change in 0..7 {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
|
||||
match change {
|
||||
0 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
|
||||
.unwrap();
|
||||
}
|
||||
1 => {
|
||||
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
|
||||
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
|
||||
}
|
||||
2 => {
|
||||
app.canvas_document = Id::new_v4();
|
||||
}
|
||||
3 => {
|
||||
app.hosted.visible = true;
|
||||
}
|
||||
4 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_context(Some(json!({"different":"scope"})));
|
||||
}
|
||||
5 => {
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
Id::from_u128(BASE + 3),
|
||||
"Changed model".into(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app.source_workspace.edit(
|
||||
"unfinished source".into(),
|
||||
iced::widget::text_editor::Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 0 },
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let expected = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(delayed);
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let app = inspector(p, Id::from_u128(300));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let request = draft.reference_open(
|
||||
app.project(),
|
||||
"/baseline/semantic/facets/0/references/0/target",
|
||||
Rectangle::default(),
|
||||
);
|
||||
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
|
||||
let mut p = Project::empty();
|
||||
for n in 0..3 {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(BASE + n),
|
||||
Kind::ExchangeItem,
|
||||
Layer::Oa,
|
||||
"Signal item",
|
||||
None,
|
||||
);
|
||||
}
|
||||
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
|
||||
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
|
||||
body: EventBody::Signal {
|
||||
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
|
||||
},
|
||||
};
|
||||
let mut app = inspector(p, OWNER);
|
||||
let list = "/body/Oa/Shared/Event/body/Signal/items";
|
||||
let path = format!("{list}/0");
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
|
||||
let expected = raw(&app);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(list),
|
||||
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
|
||||
);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldVariant(
|
||||
"/body/Oa/Shared/Event/body".into(),
|
||||
json!({"Change":{"expression":"unfinished"}}),
|
||||
));
|
||||
let expected = raw(&app);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
|
||||
{
|
||||
for mode in 0..3 {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
for (id, layer, parent) in [
|
||||
(100, Layer::Oa, None),
|
||||
(101, Layer::Sa, None),
|
||||
(102, Layer::Sa, None),
|
||||
(103, Layer::Oa, Some(Id::from_u128(100))),
|
||||
(104, Layer::Sa, Some(Id::from_u128(101))),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
if parent.is_some() {
|
||||
Kind::DataField
|
||||
} else {
|
||||
Kind::DataType
|
||||
},
|
||||
layer,
|
||||
"Record",
|
||||
parent,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
let facet = match mode {
|
||||
0 => FacetSelector::Data(DataFacet::RecordSupertype),
|
||||
1 => FacetSelector::Data(DataFacet::Constructor),
|
||||
_ => FacetSelector::Child(KeyPair {
|
||||
oa: Some(Id::from_u128(103)),
|
||||
sa: Some(Id::from_u128(104)),
|
||||
}),
|
||||
};
|
||||
let path = if mode == 2 {
|
||||
"/parent"
|
||||
} else {
|
||||
"/body/Sa/Shared/DataType/definition/Record/supertype"
|
||||
};
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
|
||||
if mode == 2 {
|
||||
// A paired attachment is a real catalog facet only after explicit
|
||||
// correspondence; selecting an unstaged synthetic pair is rejected.
|
||||
for (id, oa) in [(103, true), (104, false)] {
|
||||
let choice = crate::app::Choice {
|
||||
id: Id::from_u128(id),
|
||||
label: format!("Record · @object_{id}"),
|
||||
};
|
||||
send_review(
|
||||
&mut app,
|
||||
if oa {
|
||||
RowEvent::ReferenceOa(choice)
|
||||
} else {
|
||||
RowEvent::ReferenceSa(choice)
|
||||
},
|
||||
);
|
||||
}
|
||||
send_review(&mut app, RowEvent::AddReference);
|
||||
}
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let record = if mode == 2 {
|
||||
Id::from_u128(104)
|
||||
} else {
|
||||
Id::from_u128(101)
|
||||
};
|
||||
assert_eq!(
|
||||
app.reference_draft(&host, path)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
|
||||
review(&app).selected,
|
||||
review(&app).decisions,
|
||||
review(&app).error
|
||||
)
|
||||
})
|
||||
.original,
|
||||
record
|
||||
);
|
||||
let before = review(&app).recovery_snapshot().unwrap();
|
||||
let plan = serde_json::to_value(&review(&app).plan).unwrap();
|
||||
let proposed = review(&app).proposed.clone();
|
||||
let source = app.document.bundle().clone();
|
||||
open(&mut app, &host, path);
|
||||
search(&mut app, "@object_102 SA");
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
|
||||
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
|
||||
assert_eq!(review(&app).proposed, proposed);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
let old = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
|
||||
);
|
||||
let switched = review(&app).recovery_snapshot().unwrap();
|
||||
let _ = app.update(old);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
|
||||
send_review(&mut app, RowEvent::Select(facet));
|
||||
open(&mut app, &host, path);
|
||||
choose(&mut app, Id::from_u128(102));
|
||||
let draft = app.reference_draft(&host, path).unwrap();
|
||||
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
|
||||
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
|
||||
assert_eq!(draft.materialize().unwrap(), expected_record);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"A choice only stages the active review candidate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod mounted;
|
||||
|
||||
#[test]
|
||||
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
|
||||
let mut p = Project::empty();
|
||||
for (id, layer) in [
|
||||
(100, Layer::Oa),
|
||||
(101, Layer::Sa),
|
||||
(102, Layer::Sa),
|
||||
(110, Layer::Oa),
|
||||
(111, Layer::Sa),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
Kind::DataType,
|
||||
layer,
|
||||
"Record",
|
||||
None,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(310),
|
||||
Id::from_u128(110),
|
||||
Id::from_u128(111),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
|
||||
let old_open = request(&app, &host, path);
|
||||
open(&mut app, &host, path);
|
||||
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
for event in [
|
||||
semantic_review::Event::ChooseTrace(crate::app::Choice {
|
||||
id: Id::from_u128(310),
|
||||
label: "Second exact trace".into(),
|
||||
}),
|
||||
semantic_review::Event::OpenTrace,
|
||||
] {
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
|
||||
}
|
||||
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
|
||||
let expected = review(&app).recovery_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(old_choice);
|
||||
let Host::Review { address, facet } = host else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(
|
||||
semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event: RowEvent::ReferenceOpen(old_open),
|
||||
},
|
||||
)));
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
//! Real field + app-root overlay interaction. The null text renderer lets the
|
||||
//! same retained widget tree exercise focus and geometry without a GPU/window.
|
||||
use super::*;
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Layout, Shell, layout,
|
||||
widget::{Id as WidgetId, Operation, Tree, operation},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::{Space, column, container, opaque, scrollable, stack, text_input},
|
||||
};
|
||||
use oa_core::transitions::TransitionDirection;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard;
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Fields {
|
||||
values: Vec<(WidgetId, Rectangle, bool)>,
|
||||
scrolls: Vec<(Rectangle, Vector)>,
|
||||
}
|
||||
impl Operation for Fields {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.values.push((id.clone(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.scrolls.push((bounds, translation));
|
||||
}
|
||||
}
|
||||
impl Fields {
|
||||
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
|
||||
self.values
|
||||
.iter()
|
||||
.find(|(actual, _, _)| actual == id)
|
||||
.map(|(_, bounds, focused)| (*bounds, *focused))
|
||||
.expect("Mounted field identity")
|
||||
}
|
||||
}
|
||||
fn view(
|
||||
app: &App,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
) -> Element<'_, Message, Theme, ()> {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let form = column![
|
||||
text_input("Name", &draft.inputs["/name"].raw)
|
||||
.id("before-reference")
|
||||
.on_input(|raw| Message::Field("/name".into(), raw)),
|
||||
adapter::field(draft, app.project(), PATH),
|
||||
text_input("Description", &draft.inputs["/description"].raw)
|
||||
.id("after-reference")
|
||||
.on_input(|raw| Message::Field("/description".into(), raw)),
|
||||
]
|
||||
.spacing(9);
|
||||
let form = trigger::scope(form, draft.reference_session());
|
||||
let base: Element<'_, Message, Theme, ()> = if scrolled {
|
||||
container(
|
||||
scrollable(column![Space::new().height(500), form])
|
||||
.id("reference-scroll")
|
||||
.height(180),
|
||||
)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
container(form).width(300).height(iced::Fill).into()
|
||||
};
|
||||
let base: Element<'_, Message, Theme, ()> = if nested {
|
||||
scrollable(column![Space::new().height(400), base])
|
||||
.id("outer-reference-scroll")
|
||||
.height(180)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let base = if show_review {
|
||||
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
match state.reference_form(app.project()) {
|
||||
Some(draft) => {
|
||||
let mut content = column![];
|
||||
if let Some(raw) = &state.operation_omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", raw)
|
||||
.id("review-rationale")
|
||||
.on_input(|raw| {
|
||||
Message::Workflow(workflow::Event::semantic(
|
||||
state.address_event(RowEvent::OperationRationale(raw)),
|
||||
))
|
||||
}),
|
||||
);
|
||||
}
|
||||
content = content.push(trigger::scope(
|
||||
adapter::field(draft, app.project(), "/parent").map(|message| {
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Message::Workflow(workflow::Event::semantic(
|
||||
state.address_event(RowEvent::ReferenceOpen(request)),
|
||||
))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}),
|
||||
draft.reference_session(),
|
||||
));
|
||||
opaque(container(trigger::modal(content)).width(300).height(180))
|
||||
}
|
||||
None => Space::new().into(),
|
||||
}
|
||||
}
|
||||
_ => Space::new().into(),
|
||||
};
|
||||
stack![base, review].into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
app.reference_overlay(base)
|
||||
}
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(360., 320.),
|
||||
scrolled: false,
|
||||
nested: false,
|
||||
show_review: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn frame(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Fields, Option<Rectangle>) {
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (input, point) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
input,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
input,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut fields = Fields::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut fields);
|
||||
(
|
||||
messages,
|
||||
fields,
|
||||
overlay_node.map(|node| node.children()[0].bounds()),
|
||||
)
|
||||
}
|
||||
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
|
||||
self.frame(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
async fn perform(&mut self, app: &App, task: Task<Message>) {
|
||||
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
|
||||
return;
|
||||
};
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Chooser only requests scoped widget focus")
|
||||
};
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
loop {
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), operation.as_mut());
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => operation = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
self.perform(app, task).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn key(key: keyboard::key::Named, shift: bool) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
},
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
|
||||
raw.chars()
|
||||
.map(|character| {
|
||||
let raw = character.to_string();
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(raw.clone().into()),
|
||||
modified_key: keyboard::Key::Character(raw.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(raw.into()),
|
||||
repeat: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("before-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Tab reaches the real reference trigger"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let popup = mount
|
||||
.frame(&app, &[])
|
||||
.2
|
||||
.expect("App root overlay is mounted");
|
||||
assert!(
|
||||
popup.x >= 0.
|
||||
&& popup.y >= 0.
|
||||
&& popup.x + popup.width <= 360.
|
||||
&& popup.y + popup.height <= 320.
|
||||
);
|
||||
let mut inputs = typing("@controller_0720");
|
||||
inputs.push((key(keyboard::key::Named::Escape, false), None));
|
||||
let messages = mount.frame(&app, &inputs).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Escape returns to its exact reference field"
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
|
||||
.0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&WidgetId::new(expected)).1,
|
||||
"Chooser Tab stays within the originating form"
|
||||
);
|
||||
}
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
|
||||
{
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let mut mount = Mount::default();
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("@controller_0720")).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1);
|
||||
let delayed_focus = app.update(messages.into_iter().next().unwrap());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(PATH),
|
||||
Some(&json!(Id::from_u128(BASE + 720)))
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
// A structural edit occurs after Choose but before its asynchronous focus.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, Value::Null)
|
||||
.unwrap();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
mount.perform(&app, delayed_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"A missing old field target makes no focus changes"
|
||||
);
|
||||
assert!(
|
||||
!fields
|
||||
.field(&trigger::field_id(
|
||||
app.draft.as_ref().unwrap().reference_generation(),
|
||||
PATH
|
||||
))
|
||||
.1
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
..Default::default()
|
||||
};
|
||||
mount.frame(&app, &[]);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
|
||||
)
|
||||
.await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let content = fields.field(&id).0;
|
||||
let (viewport, offset) = fields.scrolls[0];
|
||||
assert!(offset.y > 400., "Fixture is genuinely scrolled");
|
||||
let expected = Rectangle {
|
||||
x: content.x - offset.x,
|
||||
y: content.y - offset.y,
|
||||
..content
|
||||
}
|
||||
.intersection(&viewport)
|
||||
.unwrap();
|
||||
let messages = mount.click(&app, expected.center());
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Scrolled field should emit one actual Open")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let close = app.update(Message::Escape);
|
||||
mount.perform(&app, close).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Focused scrolled field opens without a pointer")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
|
||||
for nested in [false, true] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
nested,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"Keyboard reveal must not author any input"
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).1);
|
||||
assert!(
|
||||
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
|
||||
"Every enclosing viewport reveals fresh focus"
|
||||
);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
assert!(
|
||||
fields.field(&id).0.y - offset >= 180.,
|
||||
"The next reference starts below the fold"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(messages.is_empty());
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(focused);
|
||||
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
1,
|
||||
"A newly revealed field opens through Enter"
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if nested {
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"outer-reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(
|
||||
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
|
||||
for backwards in [false, true] {
|
||||
let app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let forward = [
|
||||
WidgetId::new("before-reference"),
|
||||
reference.clone(),
|
||||
WidgetId::new("after-reference"),
|
||||
];
|
||||
let reverse = [
|
||||
WidgetId::new("after-reference"),
|
||||
reference,
|
||||
WidgetId::new("before-reference"),
|
||||
];
|
||||
let expected = if backwards { &reverse } else { &forward };
|
||||
for n in 0..7 {
|
||||
let (messages, fields, popup) =
|
||||
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(fields.field(&expected[n % 3]).1);
|
||||
assert_eq!(
|
||||
fields
|
||||
.values
|
||||
.iter()
|
||||
.filter(|(_, _, focused)| *focused)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(raw(&app), original);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let source = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let session = app.draft.as_ref().unwrap().reference_session();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().reference_session(),
|
||||
session,
|
||||
"Old Inspector really remains mounted"
|
||||
);
|
||||
assert_eq!(
|
||||
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
|
||||
id
|
||||
);
|
||||
let review_before = review(&app).recovery_snapshot().unwrap();
|
||||
let review_field = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
!fields.field(&id).1,
|
||||
"A live old field underneath a modal cannot receive the stale focus task"
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert!(!fields.field(&review_field).1);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(
|
||||
fields.field(&review_field).1,
|
||||
"Review entry ignores retained focus under its modal"
|
||||
);
|
||||
assert!(
|
||||
!fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Modal focus acquisition deactivates background inputs"
|
||||
);
|
||||
let messages = mount.frame(&app, &typing("x")).0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"A character at the focused Review trigger cannot type into the Inspector below it"
|
||||
);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(fields.field(&review_field).1);
|
||||
assert!(!fields.field(&id).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let saved = review(&app).recovery_snapshot().unwrap();
|
||||
let id = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Fixture retains Inspector focus before the modal receives input"
|
||||
);
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert!(matches!(
|
||||
&app.reference_authoring.opened.as_ref().unwrap().host,
|
||||
Host::Review { .. }
|
||||
));
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
let messages = mount.click(&app, Point::new(359., 319.));
|
||||
assert!(matches!(
|
||||
messages.as_slice(),
|
||||
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
|
||||
semantic_review::Event::Row {
|
||||
event: RowEvent::ReferenceEvent(chooser::Event {
|
||||
action: Action::Close(Close::Outside),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(task).is_none(),
|
||||
"Outside dismissal must not request focus or apply"
|
||||
);
|
||||
}
|
||||
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
|
||||
{
|
||||
let mut p = persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|element| element.alias == "operation_probe_43")
|
||||
.unwrap()
|
||||
.id;
|
||||
let allocation = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|relation| relation.alias == "operation_probe_30")
|
||||
.unwrap()
|
||||
.id;
|
||||
// The boundary fixture deliberately has no selected operation. Author one
|
||||
// through the validated command path before testing reverse omission.
|
||||
let mut message = p.elements[&sa].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = message.shared_mut()
|
||||
else {
|
||||
panic!("The operation fixture must contain an Exchange call")
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(allocation),
|
||||
});
|
||||
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
|
||||
assert!(matches!(
|
||||
oa_core::operations::resolve(&p, sa).unwrap(),
|
||||
oa_core::operations::OperationResolution::Selected(value)
|
||||
if value.allocation == allocation
|
||||
));
|
||||
let inspector_id = Id::new_v4();
|
||||
element(
|
||||
&mut p,
|
||||
inspector_id,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Inspector record",
|
||||
None,
|
||||
);
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
|
||||
.unwrap();
|
||||
let oa = p
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Operational message",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(oa);
|
||||
let trace_id = Id::new_v4();
|
||||
trace(&mut p, trace_id, oa, sa);
|
||||
let mut app = inspector(p, inspector_id);
|
||||
let source = app.document.bundle().clone();
|
||||
let inspector_raw = raw(&app);
|
||||
let _ = app.update(Message::StartSemanticReview(trace_id));
|
||||
let _ = app.update(Message::Workflow(workflow::Event::semantic(
|
||||
semantic_review::Event::Direction(TransitionDirection::SaToOa),
|
||||
)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send_review(&mut app, RowEvent::OperationOmission(true));
|
||||
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
|
||||
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
|
||||
let candidate = serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
|
||||
review(&app).selected,
|
||||
review(&app).decisions,
|
||||
review(&app).error
|
||||
)
|
||||
})
|
||||
.recovery_snapshot(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("review-rationale")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("Engineering reason")).0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
"Engineering reason".chars().count(),
|
||||
"Every key reaches the legitimate sibling inside Review"
|
||||
);
|
||||
assert!(messages.iter().all(|message| matches!(
|
||||
message,
|
||||
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
|
||||
event: RowEvent::OperationRationale(_),
|
||||
..
|
||||
})
|
||||
)));
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
review(&app).operation_omission.as_deref(),
|
||||
Some("Engineering reason")
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.field(&WidgetId::new("review-rationale"))
|
||||
.1
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
)
|
||||
.unwrap(),
|
||||
candidate
|
||||
);
|
||||
assert_eq!(raw(&app), inspector_raw);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
|
||||
}
|
||||
fn row_event(app: &mut App, event: RowEvent) {
|
||||
let event = state(app).address_event(event);
|
||||
send(app, Message::Workflow(Event::semantic(event)));
|
||||
send(app, Message::Workflow(Event::Semantic(event)));
|
||||
}
|
||||
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
|
||||
let mut p = Project::empty();
|
||||
|
||||
+279
-509
File diff suppressed because it is too large
Load Diff
@@ -1,243 +0,0 @@
|
||||
//! Shared presentation and input contract; contains no model commands.
|
||||
use crate::app::{App, Message};
|
||||
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
|
||||
use iced::{Element, Length::Fill, Point, Theme, Vector};
|
||||
use oa_core::Id;
|
||||
mod control;
|
||||
mod focus;
|
||||
mod viewport;
|
||||
pub use control::Control;
|
||||
pub use viewport::Viewport;
|
||||
|
||||
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
|
||||
!app.busy
|
||||
&& !app.hosted.visible
|
||||
&& app.workflow.is_none()
|
||||
&& app.file_prompt.is_none()
|
||||
&& !app.delete_pending
|
||||
&& app.pending.is_none()
|
||||
&& app.model_menu.is_none()
|
||||
&& !app.reference_input_active()
|
||||
}
|
||||
|
||||
pub fn controls<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
scale: f32,
|
||||
details: Message,
|
||||
focus: Option<Message>,
|
||||
zoom: impl Fn(Option<f32>) -> Message,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
|
||||
let message = message.map(|message| intent(scope, name, message));
|
||||
Control::new(
|
||||
button(text(label).size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(message.clone()),
|
||||
scope,
|
||||
name,
|
||||
keyboard,
|
||||
message,
|
||||
publish,
|
||||
)
|
||||
};
|
||||
row![
|
||||
control("Details", "Details", Some(details)),
|
||||
tooltip(control("Canvas", "Canvas", focus),
|
||||
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
].spacing(4).align_y(iced::Center).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
impl Scope {
|
||||
pub(crate) fn current(app: &App) -> Option<Self> {
|
||||
if let Some(context) = app.scenario_context() {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
} else {
|
||||
let context = app.topology_context()?;
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn input_owner(app: &App) -> Option<Self> {
|
||||
if keyboard_enabled(app)
|
||||
&& !app.behavior.has_pending()
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
{
|
||||
Self::current(app)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
|
||||
iced::advanced::widget::Id::from(format!(
|
||||
"behavior-control:{}:{}:{}:{name}",
|
||||
self.document, self.owner, self.revision
|
||||
))
|
||||
}
|
||||
}
|
||||
/// Only a live presentation intent may carry a toolbar action across its own pan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Intent {
|
||||
pub scope: Scope,
|
||||
pub name: &'static str,
|
||||
pub message: Box<Message>,
|
||||
}
|
||||
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
|
||||
Message::BehaviorControl(Intent {
|
||||
scope,
|
||||
name,
|
||||
message: Box::new(message),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Event {
|
||||
CancelGesture,
|
||||
Pan(Vector),
|
||||
Zoom { factor: f32, at: Point, base: f32 },
|
||||
Focus,
|
||||
Traverse(bool),
|
||||
ControlFocus(&'static str),
|
||||
ToggleFocus,
|
||||
Leave,
|
||||
}
|
||||
|
||||
pub use focus::{focus_task, root as focus_root};
|
||||
/// Fold relative input in event order. The base scale is used only on leaving Fit.
|
||||
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
|
||||
match event {
|
||||
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
let next = *pan + delta;
|
||||
if next.x.is_finite() && next.y.is_finite() && next != *pan {
|
||||
*pan = next;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Event::Zoom { factor, at, base }
|
||||
if factor.is_finite()
|
||||
&& factor > 0.
|
||||
&& base.is_finite()
|
||||
&& base > 0.
|
||||
&& at.x.is_finite()
|
||||
&& at.y.is_finite() =>
|
||||
{
|
||||
let old = if *fit { base } else { zoom.unwrap_or(base) };
|
||||
let next = (old * factor).clamp(0.05, 3.);
|
||||
if old == next {
|
||||
return false;
|
||||
}
|
||||
let anchor = Vector::new(at.x, at.y);
|
||||
let next_pan = anchor - (anchor - *pan) * (next / old);
|
||||
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
*pan = next_pan;
|
||||
*zoom = Some(next);
|
||||
*fit = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
|
||||
pub fn palette<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
|
||||
) -> Element<'a, Message> {
|
||||
let mut tools = column![].spacing(3);
|
||||
for (glyph, label, selected, message) in entries {
|
||||
let message = message.map(|message| intent(scope, glyph, message));
|
||||
tools = tools.push(tooltip(
|
||||
container(Control::new(
|
||||
button(text(glyph).size(12))
|
||||
.width(34)
|
||||
.height(30)
|
||||
.padding(3)
|
||||
.style(if selected {
|
||||
button::primary
|
||||
} else {
|
||||
crate::style::nav
|
||||
})
|
||||
.on_press_maybe(message.clone()),
|
||||
scope,
|
||||
glyph,
|
||||
keyboard,
|
||||
message,
|
||||
publish,
|
||||
))
|
||||
.id(format!("behavior-tool:{glyph}")),
|
||||
container(text(label).size(12))
|
||||
.padding(7)
|
||||
.style(crate::style::card),
|
||||
tooltip::Position::Left,
|
||||
));
|
||||
}
|
||||
container(scrollable(tools).id(scope.control_id("palette-scroll")))
|
||||
.width(40)
|
||||
.height(Fill)
|
||||
.padding(3)
|
||||
.style(crate::style::panel)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Header and palette are permanent. Details/editors overlay the same viewport.
|
||||
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: Element<'a, Message, Theme, R>,
|
||||
palette: Element<'a, Message, Theme, R>,
|
||||
canvas: Element<'a, Message, Theme, R>,
|
||||
editor: Option<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
let mut body = stack![canvas];
|
||||
if let Some(editor) = editor {
|
||||
body = body.push(
|
||||
container(opaque(
|
||||
container(editor)
|
||||
.width(300)
|
||||
.height(Fill)
|
||||
.style(crate::style::card),
|
||||
))
|
||||
.align_right(Fill)
|
||||
.height(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
column![header, row![body, palette].height(Fill)]
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
@@ -1,256 +0,0 @@
|
||||
//! A real focusable keyboard target around the existing styled pointer button.
|
||||
use super::{Event as CameraEvent, Scope, focus::Member};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Operation, Tree, operation, tree},
|
||||
};
|
||||
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
identity: Option<iced::advanced::widget::Id>,
|
||||
focused: bool,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
}
|
||||
}
|
||||
pub struct Control<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
scope: Scope,
|
||||
name: &'static str,
|
||||
enabled: bool,
|
||||
activate: Option<Message>,
|
||||
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
scope: Scope,
|
||||
name: &'static str,
|
||||
enabled: bool,
|
||||
activate: Option<Message>,
|
||||
publish: impl Fn(CameraEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scope,
|
||||
name,
|
||||
enabled: enabled && activate.is_some(),
|
||||
activate,
|
||||
publish: Box::new(publish),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Input>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Input {
|
||||
identity: Some(self.scope.control_id(self.name)),
|
||||
focused: false,
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
let identity = self.scope.control_id(self.name);
|
||||
if state.identity.as_ref() != Some(&identity) || !self.enabled {
|
||||
*state = Input {
|
||||
identity: Some(identity),
|
||||
focused: false,
|
||||
};
|
||||
}
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
let id = self.scope.control_id(self.name);
|
||||
operation.container(Some(&id), layout.bounds());
|
||||
if self.enabled {
|
||||
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
|
||||
operation.focusable(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Input>(),
|
||||
);
|
||||
}
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if self.enabled {
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers,
|
||||
repeat,
|
||||
..
|
||||
}) = event
|
||||
&& state.focused
|
||||
{
|
||||
use keyboard::{Key, key::Named};
|
||||
let action = match key.as_ref() {
|
||||
Key::Named(Named::Tab) => {
|
||||
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
|
||||
}
|
||||
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
|
||||
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
|
||||
Some((self.publish)(CameraEvent::ToggleFocus))
|
||||
}
|
||||
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
|
||||
self.activate.clone()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
shell.publish(action);
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && !layout
|
||||
.bounds()
|
||||
.intersection(viewport)
|
||||
.is_some_and(|bounds| cursor.is_over(bounds))
|
||||
{
|
||||
state.focused = false;
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.focused = false;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
if self.enabled && tree.state.downcast_ref::<Input>().focused {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: crate::style::INK,
|
||||
width: 2.,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(control: Control<'a, Message, R>) -> Self {
|
||||
Element::new(control)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
|
||||
use super::{Event, Scope};
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
|
||||
pub(super) struct Member(pub Scope);
|
||||
|
||||
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
scope,
|
||||
valid: false,
|
||||
event,
|
||||
members: vec![],
|
||||
focused: None,
|
||||
palette: None,
|
||||
})
|
||||
}
|
||||
struct Collect {
|
||||
scope: Scope,
|
||||
valid: bool,
|
||||
event: Event,
|
||||
members: Vec<(Id, Rectangle)>,
|
||||
focused: Option<usize>,
|
||||
palette: Option<(Rectangle, Rectangle)>,
|
||||
}
|
||||
impl<T: Send + 'static> Operation<T> for Collect {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
|
||||
visit(self);
|
||||
}
|
||||
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = value.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.scope);
|
||||
}
|
||||
if self.valid
|
||||
&& value
|
||||
.downcast_ref::<Member>()
|
||||
.is_some_and(|m| m.0 == self.scope)
|
||||
&& let Some(id) = id
|
||||
{
|
||||
self.members.push((id.clone(), bounds));
|
||||
}
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
|
||||
if value.is_focused() {
|
||||
self.focused = self
|
||||
.members
|
||||
.iter()
|
||||
.position(|(member, _)| Some(member) == id)
|
||||
.or(self.focused);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.scope.control_id("palette-scroll")) {
|
||||
self.palette = Some((bounds, content));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<T> {
|
||||
let canvas = self.scope.control_id("viewport");
|
||||
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
|
||||
// owners expose no members; a delayed request cannot clear their focus.
|
||||
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let target = match self.event {
|
||||
Event::Focus => Some(canvas),
|
||||
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
|
||||
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
|
||||
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
|
||||
Event::Traverse(back) => {
|
||||
let Some(current) = self.focused else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let next = if back {
|
||||
(current + self.members.len() - 1) % self.members.len()
|
||||
} else {
|
||||
(current + 1) % self.members.len()
|
||||
};
|
||||
Some(self.members[next].0.clone())
|
||||
}
|
||||
_ => return operation::Outcome::None,
|
||||
};
|
||||
if target
|
||||
.as_ref()
|
||||
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
|
||||
{
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let reveal = target.as_ref().and_then(|target| {
|
||||
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
|
||||
let (bounds, content) = self.palette?;
|
||||
// Only the palette has member controls inside this scrollable.
|
||||
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
|
||||
(field.center_y() - bounds.y - bounds.height / 2.)
|
||||
.clamp(0., (content.height - bounds.height).max(0.))
|
||||
})
|
||||
});
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope,
|
||||
valid: false,
|
||||
target,
|
||||
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
|
||||
palette: self.scope.control_id("palette-scroll"),
|
||||
reveal,
|
||||
}))
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
scope: Scope,
|
||||
valid: bool,
|
||||
target: Option<Id>,
|
||||
members: Vec<Id>,
|
||||
palette: Id,
|
||||
reveal: Option<f32>,
|
||||
}
|
||||
impl<T> Operation<T> for Apply {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
|
||||
visit(self);
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = value.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.scope);
|
||||
}
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if !self.valid {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|target| Some(target) == id)
|
||||
{
|
||||
state.focus();
|
||||
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& id == Some(&self.palette)
|
||||
&& let Some(y) = self.reveal
|
||||
{
|
||||
state.scroll_to(operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(y),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every Collect/Apply pass observes current ownership before touching any field.
|
||||
struct Owner(Option<Scope>);
|
||||
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
owner: Option<Scope>,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Scope>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
owner,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &InputEvent,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
use super::*;
|
||||
use crate::app::{App, Message};
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
use iced::futures::{
|
||||
StreamExt,
|
||||
stream::{BoxStream, SelectAll},
|
||||
};
|
||||
use iced::{Rectangle, Size, mouse};
|
||||
use iced_runtime::{
|
||||
Action,
|
||||
user_interface::{Cache, UserInterface},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
|
||||
|
||||
// iced_winit drains messages while collecting immediately ready actions. A
|
||||
// pending Task stream survives until its widget operation sends its result.
|
||||
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
|
||||
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
|
||||
let waker = iced::futures::task::noop_waker();
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
loop {
|
||||
match stream.poll_next_unpin(&mut context) {
|
||||
std::task::Poll::Ready(Some(action)) => ready.push(action),
|
||||
std::task::Poll::Ready(None) => break,
|
||||
std::task::Poll::Pending => {
|
||||
pending.push(stream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Geometry {
|
||||
pub containers: HashMap<widget::Id, Rectangle>,
|
||||
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
|
||||
pub text_inputs: Vec<Rectangle>,
|
||||
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
self.focused.push((id.cloned(), bounds, state.is_focused()));
|
||||
}
|
||||
fn text_input(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: &mut dyn widget::operation::TextInput,
|
||||
) {
|
||||
self.text_inputs.push(bounds);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if let Some(id) = id {
|
||||
self.containers.insert(id.clone(), bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.scrolls.insert(id.clone(), (bounds, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Geometry {
|
||||
pub fn bounds(&self, id: &str) -> Rectangle {
|
||||
self.containers[&widget::Id::from(id.to_owned())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount the actual App root, production widgets and renderer. Events are
|
||||
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
|
||||
/// This deliberately does not seed any private canvas gesture state.
|
||||
pub(crate) struct Mounted {
|
||||
pub renderer: iced::Renderer,
|
||||
cache: Cache,
|
||||
pub operations: usize,
|
||||
}
|
||||
impl Mounted {
|
||||
pub async fn unshown() -> Self {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
|
||||
.await
|
||||
.expect("production mounted tests require the supported WGPU headless renderer");
|
||||
Self {
|
||||
renderer,
|
||||
cache: Cache::new(),
|
||||
operations: 0,
|
||||
}
|
||||
}
|
||||
pub async fn new(app: &mut App) -> Self {
|
||||
let mut mounted = Self::unshown().await;
|
||||
mounted.settle(app).await;
|
||||
mounted
|
||||
}
|
||||
pub fn geometry(&mut self, app: &App) -> Geometry {
|
||||
let mut geometry = Geometry::default();
|
||||
self.operate(app, &mut geometry);
|
||||
geometry
|
||||
}
|
||||
/// One operation pass, including every nested overlay, via installed Iced.
|
||||
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
ui.operate(&self.renderer, operation);
|
||||
self.cache = ui.into_cache();
|
||||
}
|
||||
pub async fn settle(&mut self, app: &mut App) {
|
||||
for _ in 0..3 {
|
||||
self.batch(
|
||||
app,
|
||||
&[iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
))],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
pub async fn batch(
|
||||
&mut self,
|
||||
app: &mut App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
let messages = self.input(app, events, cursor);
|
||||
let mut ready = vec![];
|
||||
let mut pending = Pending::new();
|
||||
for message in messages.iter().cloned() {
|
||||
enqueue(app.update(message), &mut ready, &mut pending);
|
||||
}
|
||||
self.drain(app, ready, pending).await;
|
||||
messages
|
||||
}
|
||||
pub fn input(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
let mut messages = vec![];
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
let _ = ui.update(
|
||||
events,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
messages
|
||||
}
|
||||
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
||||
let mut ready = vec![];
|
||||
let mut pending = Pending::new();
|
||||
enqueue(task, &mut ready, &mut pending);
|
||||
self.drain(app, ready, pending).await;
|
||||
}
|
||||
async fn drain(
|
||||
&mut self,
|
||||
app: &mut App,
|
||||
mut ready: Vec<Action<Message>>,
|
||||
mut pending: Pending,
|
||||
) {
|
||||
loop {
|
||||
let mut outputs = vec![];
|
||||
if !ready.is_empty() {
|
||||
// Build only after all App messages in this cycle have run.
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
for action in ready.drain(..) {
|
||||
match action {
|
||||
Action::Output(message) => outputs.push(message),
|
||||
Action::Widget(mut operation) => {
|
||||
self.operations += 1;
|
||||
loop {
|
||||
ui.operate(&self.renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(next) => operation = next,
|
||||
widget::operation::Outcome::None
|
||||
| widget::operation::Outcome::Some(()) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
action => {
|
||||
panic!("Unexpected native action in mounted App harness: {action:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cache = ui.into_cache();
|
||||
}
|
||||
for message in outputs {
|
||||
enqueue(app.update(message), &mut ready, &mut pending);
|
||||
}
|
||||
if !ready.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(action) = pending.next().await else {
|
||||
break;
|
||||
};
|
||||
ready.push(action);
|
||||
let waker = iced::futures::task::noop_waker();
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
|
||||
ready.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
// update initializes the nested overlay layout used by draw.
|
||||
let mut messages = vec![];
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
mouse::Cursor::Unavailable,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"An empty draw preparation must not drop App messages"
|
||||
);
|
||||
ui.draw(
|
||||
&mut self.renderer,
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: crate::style::INK,
|
||||
},
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
Headless::screenshot(
|
||||
&mut self.renderer,
|
||||
Size::new(app.window_size.width as u32, app.window_size.height as u32),
|
||||
1.,
|
||||
crate::style::BG,
|
||||
)
|
||||
}
|
||||
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
|
||||
let mut operation = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::from(id.to_owned()),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
);
|
||||
self.operate(app, &mut operation);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
|
||||
use iced::keyboard::{self, Key};
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(named),
|
||||
modified_key: Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Unidentified(
|
||||
keyboard::key::NativeCode::Unidentified,
|
||||
),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
|
||||
for scale in [0.05, 0.5, 1., 2.] {
|
||||
let mut pan = Vector::new(-130., 75.);
|
||||
let mut zoom = Some(scale);
|
||||
let mut fit = false;
|
||||
let at = Point::new(171., 93.);
|
||||
let world = (at - Point::ORIGIN - pan) / scale;
|
||||
change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fit,
|
||||
Event::Zoom {
|
||||
factor: 1.25,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
);
|
||||
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
|
||||
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
|
||||
let before = (pan, zoom, fit);
|
||||
for input in [
|
||||
Event::Pan(Vector::new(f32::INFINITY, 0.)),
|
||||
Event::Zoom {
|
||||
factor: f32::NAN,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
Event::Zoom {
|
||||
factor: 0.,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
] {
|
||||
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
|
||||
assert_eq!((pan, zoom, fit), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
|
||||
use iced::widget::Space;
|
||||
let scope = Scope {
|
||||
document: Id::new_v4(),
|
||||
owner: Id::new_v4(),
|
||||
revision: 2,
|
||||
epoch: 0,
|
||||
};
|
||||
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
|
||||
Space::new().width(Fill).height(Fill),
|
||||
scope,
|
||||
1.,
|
||||
true,
|
||||
|e| e,
|
||||
)
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
|
||||
)
|
||||
.move_to(Point::new(30., 80.));
|
||||
let start = Point::new(90., 160.);
|
||||
let middle = Point::new(125., 175.);
|
||||
let end = Point::new(145., 185.);
|
||||
let mut messages = vec![];
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
mouse::Event::CursorMoved { position: middle },
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&iced::Event::Mouse(event),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Available(end),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::with_size(Size::new(700., 600.)),
|
||||
);
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
|
||||
let total = messages
|
||||
.iter()
|
||||
.fold(Vector::ZERO, |total, event| match event {
|
||||
Event::Pan(delta) => total + *delta,
|
||||
_ => total,
|
||||
});
|
||||
assert_eq!(total, end - start);
|
||||
}
|
||||
|
||||
pub(crate) fn scope(app: &App) -> Scope {
|
||||
if let Some(context) = app.scenario_context() {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
} else {
|
||||
let context = app.topology_context().expect("mounted behavior owner");
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
|
||||
let id = scope(app).control_id(name);
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
|
||||
}
|
||||
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
|
||||
for _ in 0..32 {
|
||||
if focused(mounted, app, name) {
|
||||
return;
|
||||
}
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[key(iced::keyboard::key::Named::Tab)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
panic!("Keyboard traversal did not reach {name}");
|
||||
}
|
||||
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
|
||||
messages.iter().find(|m| matches!(m,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
|
||||
)).expect("actual nonzero camera delta from a mounted gesture").clone()
|
||||
}
|
||||
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
pub(crate) async fn release_middle(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
point: Point,
|
||||
) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
|
||||
let width = size.width as usize;
|
||||
assert_eq!(before.len(), width * size.height as usize * 4);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let mut changed = 0;
|
||||
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
|
||||
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
|
||||
if x as f32 - bounds.x > 2.
|
||||
&& bounds.x + bounds.width - x as f32 > 2.
|
||||
&& y as f32 - bounds.y > 2.
|
||||
&& bounds.y + bounds.height - y as f32 > 2.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = (y * width + x) * 4;
|
||||
if before[start..start + 3] != after[start..start + 3] {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
changed >= bounds.width.min(bounds.height) as usize,
|
||||
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn character(value: &str) -> iced::Event {
|
||||
let mut event = key(iced::keyboard::key::Named::Space);
|
||||
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
text,
|
||||
..
|
||||
}) = &mut event
|
||||
{
|
||||
*key = iced::keyboard::Key::Character(value.into());
|
||||
*modified_key = key.clone();
|
||||
*text = Some(value.into());
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
/// Inspect the action without stripping the lifetime from retained packets.
|
||||
pub(crate) fn intent_action(message: &Message) -> &Message {
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => &intent.message,
|
||||
message => message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
|
||||
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
|
||||
pub(crate) async fn retained_focus_apply(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
) -> Box<dyn widget::Operation> {
|
||||
let entry = mounted.input(
|
||||
app,
|
||||
&[key(iced::keyboard::key::Named::F6)],
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(
|
||||
entry.len(),
|
||||
1,
|
||||
"actual initial F6 must emit one canvas focus request"
|
||||
);
|
||||
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
|
||||
.expect("F6 returns a widget Task");
|
||||
let Some(Action::Widget(mut operation)) = stream.next().await else {
|
||||
panic!("F6 first action must be Collect");
|
||||
};
|
||||
mounted.operate(app, &mut *operation);
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(apply) => apply,
|
||||
_ => panic!("Live Collect must produce its guarded Apply pass"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
|
||||
use iced::keyboard::key::Named;
|
||||
let geometry = mounted.geometry(app);
|
||||
let last_bounds = geometry.containers[&scope(app).control_id(last)];
|
||||
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
|
||||
assert_eq!(offset.y, 0.);
|
||||
assert!(
|
||||
last_bounds.y >= rail.y + rail.height,
|
||||
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
|
||||
);
|
||||
mounted
|
||||
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
tab_to(mounted, app, last).await;
|
||||
let geometry = mounted.geometry(app);
|
||||
let mut visible = geometry.containers[&scope(app).control_id(last)];
|
||||
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
|
||||
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
|
||||
visible.x -= offset.x;
|
||||
visible.y -= offset.y;
|
||||
assert!(
|
||||
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
|
||||
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
|
||||
);
|
||||
let messages = mounted
|
||||
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(
|
||||
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
|
||||
),
|
||||
"the revealed last tool is keyboard activated"
|
||||
);
|
||||
assert!(focused(mounted, app, last));
|
||||
let on = mounted.pixels(app);
|
||||
mounted
|
||||
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let off = mounted.pixels(app);
|
||||
assert_drawn_focus(&off, &on, app.window_size, visible);
|
||||
}
|
||||
|
||||
pub(crate) async fn retained_toolbar_boundaries(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
tool: &'static str,
|
||||
) {
|
||||
use iced::keyboard::key::Named;
|
||||
for boundary in ["Chooser", "Dirty", "Picker"] {
|
||||
let mut packets = vec![];
|
||||
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
|
||||
packets.push(capture_toolbar(mounted, app, name));
|
||||
}
|
||||
let lifetime = app.camera_epoch;
|
||||
let epoch = app.canvas_epoch;
|
||||
let process = app.behavior.topology.camera_boundary();
|
||||
let scenario = app.behavior.scenario.camera_boundary();
|
||||
match boundary {
|
||||
"Chooser" => {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let id = crate::forms::reference_chooser::trigger::field_id(
|
||||
draft.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(field, _, _)| field.as_ref() == Some(&id))
|
||||
.expect("real Inspector parent reference trigger")
|
||||
.1;
|
||||
let request = draft.reference_open(app.project(), "/parent", bounds);
|
||||
let task = app.update(Message::FieldReferenceOpen(request));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(app.reference_input_active());
|
||||
mounted
|
||||
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!app.reference_input_active());
|
||||
}
|
||||
"Dirty" => {
|
||||
let task = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unsaved camera boundary".into(),
|
||||
));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(app.draft_dirty());
|
||||
// Every retained packet is also rejected while the owner is dirty.
|
||||
for packet in packets.iter().cloned() {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
assert!(app.draft_dirty());
|
||||
let task = app.update(Message::RevertProperties);
|
||||
mounted.perform(app, task).await;
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
"Picker" => {
|
||||
// Public transitions, with cancellation injected at the external
|
||||
// picker boundary. Never poll the native dialog future here.
|
||||
let picker = app.update(Message::Save(true));
|
||||
if cfg!(target_os = "linux") {
|
||||
assert!(app.file_prompt.is_some());
|
||||
mounted.perform(app, picker).await;
|
||||
let task = app.update(Message::CancelFile);
|
||||
mounted.perform(app, task).await;
|
||||
} else {
|
||||
assert!(app.busy, "Save As enters the real native-picker busy state");
|
||||
drop(picker);
|
||||
for packet in packets.iter().cloned() {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
let task = app.update(Message::SavePath(None));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_ne!(
|
||||
app.camera_epoch, lifetime,
|
||||
"{boundary} changes the presentation lifetime"
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"{boundary} cycle leaves semantic context unchanged"
|
||||
);
|
||||
let focus_before = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.collect::<Vec<_>>();
|
||||
let current_lifetime = app.camera_epoch;
|
||||
for packet in packets {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
assert_eq!(
|
||||
app.camera_epoch, current_lifetime,
|
||||
"{boundary} retained intents cannot start a new lifetime"
|
||||
);
|
||||
assert!(
|
||||
app.behavior.topology.camera_boundary() == process,
|
||||
"{boundary} retained toolbar packet changed process presentation"
|
||||
);
|
||||
assert!(
|
||||
app.behavior.scenario.camera_boundary() == scenario,
|
||||
"{boundary} retained toolbar packet changed scenario presentation"
|
||||
);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.collect::<Vec<_>>(),
|
||||
focus_before,
|
||||
"{boundary} retained toolbar packet stole field focus"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
|
||||
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
|
||||
mounted
|
||||
.input(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
|
||||
.expect("actual production toolbar packet")
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
use super::{Event as CameraEvent, Scope};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Operation, Tree, operation, tree},
|
||||
};
|
||||
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
|
||||
use iced::{keyboard, mouse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
scope: Option<Scope>,
|
||||
focused: bool,
|
||||
pointer: Option<Point>,
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
bounds: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pan = None;
|
||||
self.space = false;
|
||||
}
|
||||
}
|
||||
pub struct Viewport<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
scope: Scope,
|
||||
scale: f32,
|
||||
keyboard: bool,
|
||||
blank_pan: bool,
|
||||
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
scope: Scope,
|
||||
scale: f32,
|
||||
keyboard: bool,
|
||||
publish: impl Fn(CameraEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
blank_pan: false,
|
||||
publish: Box::new(publish),
|
||||
}
|
||||
}
|
||||
pub fn pan_background(mut self, enabled: bool) -> Self {
|
||||
self.blank_pan = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Input>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Input {
|
||||
scope: Some(self.scope),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if state.scope.is_none_or(|s| {
|
||||
(s.document, s.owner, s.revision)
|
||||
!= (self.scope.document, self.scope.owner, self.scope.revision)
|
||||
}) {
|
||||
*state = Input::default();
|
||||
}
|
||||
if state
|
||||
.scope
|
||||
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|
||||
|| !self.keyboard
|
||||
{
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
}
|
||||
state.scope = Some(self.scope);
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Length::Fill, Length::Fill)
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
if self.keyboard {
|
||||
let id = self.scope.control_id("viewport");
|
||||
operation.custom(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
&mut super::focus::Member(self.scope),
|
||||
);
|
||||
operation.focusable(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Input>(),
|
||||
);
|
||||
}
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
|
||||
state.bounds = Some(layout.bounds());
|
||||
if changed_bounds || !self.keyboard {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
}
|
||||
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
}
|
||||
let pointer = match event {
|
||||
Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_)
|
||||
| mouse::Event::ButtonReleased(_)
|
||||
| mouse::Event::WheelScrolled { .. },
|
||||
) => state.pointer.or_else(|| cursor.position()),
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
_ => cursor.position().or(state.pointer),
|
||||
};
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
|
||||
&& cursor.position().is_some();
|
||||
if !self.keyboard {
|
||||
state.space = false;
|
||||
}
|
||||
let mut cancel = changed_bounds;
|
||||
let mut camera = None;
|
||||
let mut capture = false;
|
||||
match event {
|
||||
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focused = false;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorLeft) => {
|
||||
state.pan = None;
|
||||
state.pointer = None;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
cancel = true;
|
||||
}
|
||||
state.focused = inside && self.keyboard;
|
||||
if inside
|
||||
&& self.keyboard
|
||||
&& (*button == mouse::Button::Middle
|
||||
|| (*button == mouse::Button::Left && state.space))
|
||||
{
|
||||
state.pan = pointer.map(|p| (*button, p));
|
||||
cancel = true;
|
||||
camera = Some(CameraEvent::CancelGesture);
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
if let Some((button, previous)) = state.pan {
|
||||
state.pan = Some((button, *position));
|
||||
camera = Some(CameraEvent::Pan(*position - previous));
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
if let Some((held, previous)) = state.pan
|
||||
&& held == *button
|
||||
{
|
||||
state.pan = None;
|
||||
camera = pointer
|
||||
.filter(|_| inside)
|
||||
.map(|p| CameraEvent::Pan(p - previous));
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
|
||||
state.pan = None;
|
||||
cancel = true;
|
||||
let y = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } => *y,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
|
||||
};
|
||||
if y != 0. {
|
||||
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
|
||||
camera = Some(CameraEvent::Zoom {
|
||||
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
|
||||
at,
|
||||
base: self.scale,
|
||||
});
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F6),
|
||||
modifiers,
|
||||
repeat: false,
|
||||
..
|
||||
}) if self.keyboard && modifiers.is_empty() => {
|
||||
camera = Some(CameraEvent::ToggleFocus);
|
||||
capture = true;
|
||||
cancel = true;
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
|
||||
if state.focused && self.keyboard =>
|
||||
{
|
||||
use keyboard::{Key, key::Named};
|
||||
match key.as_ref() {
|
||||
Key::Named(Named::Space) => {
|
||||
state.space = true;
|
||||
cancel = true;
|
||||
camera = Some(CameraEvent::CancelGesture);
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Tab) => {
|
||||
camera = Some(CameraEvent::Traverse(modifiers.shift()));
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Escape) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
camera = Some(CameraEvent::Leave);
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(key) if modifiers.is_empty() => {
|
||||
let delta = match key {
|
||||
Named::ArrowLeft => Some(Vector::new(60., 0.)),
|
||||
Named::ArrowRight => Some(Vector::new(-60., 0.)),
|
||||
Named::ArrowUp => Some(Vector::new(0., 60.)),
|
||||
Named::ArrowDown => Some(Vector::new(0., -60.)),
|
||||
_ => None,
|
||||
};
|
||||
camera = delta.map(CameraEvent::Pan);
|
||||
capture = camera.is_some();
|
||||
cancel = capture;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
|
||||
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
|
||||
{
|
||||
state.space = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cancel {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&Event::Window(iced::window::Event::Unfocused),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
if let Some(event) = camera {
|
||||
shell.publish((self.publish)(event));
|
||||
}
|
||||
if capture {
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
|
||||
return;
|
||||
}
|
||||
let child_cursor = if inside {
|
||||
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
child_cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// A semantic hit or armed creation tool gets first refusal. In Select,
|
||||
// an otherwise unused blank press starts the same camera gesture.
|
||||
if self.blank_pan
|
||||
&& self.keyboard
|
||||
&& inside
|
||||
&& !shell.is_event_captured()
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.pan = pointer.map(|p| (mouse::Button::Left, p));
|
||||
shell.publish((self.publish)(CameraEvent::CancelGesture));
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
renderer.with_layer(layout.bounds(), |renderer| {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
});
|
||||
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: crate::style::TEAL,
|
||||
width: 1.,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if tree.state.downcast_ref::<Input>().pan.is_some() {
|
||||
mouse::Interaction::Grabbing
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(viewport: Viewport<'a, Message, R>) -> Self {
|
||||
Element::new(viewport)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,6 @@ impl Picker {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Open(Layer),
|
||||
Choose(Family),
|
||||
Name(String),
|
||||
@@ -98,11 +97,6 @@ struct Browser {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
|
||||
version: u32,
|
||||
document: Id,
|
||||
layer: Layer,
|
||||
@@ -131,17 +125,6 @@ pub struct Created {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.draft.is_some()
|
||||
}
|
||||
@@ -191,49 +174,6 @@ impl State {
|
||||
.command(p)
|
||||
}
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.family != Some(Family::Machine) {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.document != p.id {
|
||||
return Err("This behavior draft belongs to another document.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Machine { layer: d.layer },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.owner = Some(id);
|
||||
let preview = next
|
||||
.source
|
||||
.preview(p, next.owner)?
|
||||
.ok_or("New owner preview is missing.")?;
|
||||
if !eligible(&preview, next.layer, Picker::Owner, id) {
|
||||
return Err("This participant cannot own the machine.".into());
|
||||
}
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Cancel => {
|
||||
self.draft = None;
|
||||
@@ -244,8 +184,6 @@ impl State {
|
||||
return Err("Create or cancel the pending behavior first.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
version: 1,
|
||||
document: p.id,
|
||||
layer,
|
||||
@@ -394,7 +332,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
|
||||
};
|
||||
format!("{} · {} · {location}", e.kind(), e.alias)
|
||||
}
|
||||
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
fn reference_label(p: &Project, id: Id) -> String {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
||||
@@ -402,15 +340,6 @@ pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Machine { layer: self.layer },
|
||||
self.owner,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.owner)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.owner, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.document != p.id {
|
||||
return Err("This draft belongs to another document.".into());
|
||||
}
|
||||
@@ -546,21 +475,6 @@ impl State {
|
||||
p: &'a Project,
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.draft.as_ref()?;
|
||||
if let Some(view) = d.source.view(p) {
|
||||
return Some(
|
||||
column![
|
||||
text(format!(
|
||||
"State machine name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
view.map(Event::Source)
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut fields = column![].spacing(10);
|
||||
let title = if let Some(family) = d.family {
|
||||
fields = fields.push(
|
||||
@@ -574,7 +488,7 @@ impl State {
|
||||
Family::Sequence => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
||||
"Owned by the {} layer root. Involve existing work after creation.",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
@@ -617,17 +531,7 @@ impl State {
|
||||
}
|
||||
}
|
||||
Family::Machine => {
|
||||
if let Some(label) = d.source.label(d.owner) {
|
||||
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
|
||||
}
|
||||
fields = fields.push(picker_view(p, d, Picker::Owner));
|
||||
fields = fields.push(
|
||||
button("Create new owner…")
|
||||
.on_press(Event::Source(super::source_draft::Event::Open(
|
||||
d.invocation,
|
||||
)))
|
||||
.padding(7),
|
||||
);
|
||||
fields = fields.push(
|
||||
checkbox(d.first_view)
|
||||
.label("Create the first state view")
|
||||
@@ -640,9 +544,9 @@ impl State {
|
||||
} else {
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let purpose = match family {
|
||||
Family::Sequence => "A path through work and exchanges",
|
||||
Family::Sequence => "A path through existing work and exchanges",
|
||||
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
||||
Family::Machine => "States and modes of an explicitly chosen participant",
|
||||
Family::Machine => "States and modes of an existing participant",
|
||||
};
|
||||
fields = fields.push(
|
||||
button(
|
||||
@@ -694,9 +598,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
|
||||
let title = match picker {
|
||||
Picker::Owner if staged_owner => "Or choose an existing participant",
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
@@ -707,17 +609,14 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
if let Some(id) = selected {
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Keep behavior field state within one editing session and reset it on replacement.
|
||||
use iced::advanced::widget::{Operation, Tree, tree};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
use oa_core::Id;
|
||||
|
||||
struct Identity(Id);
|
||||
|
||||
struct Session<'a, Message, R: renderer::Renderer> {
|
||||
identity: Id,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
identity: Id,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Session {
|
||||
identity,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Identity>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Identity(self.identity))
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let retained = tree.state.downcast_mut::<Identity>();
|
||||
if retained.0 != self.identity {
|
||||
retained.0 = self.identity;
|
||||
// Iced's keyed column preserves same-length child replacements.
|
||||
// An editing-session replacement must instead get fresh field state.
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
pub(crate) mod focus;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -99,9 +97,7 @@ pub enum CanvasEvent {
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
FormShown(Context, Id),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Shelf(Context, Shelf),
|
||||
OpenUse(Context, Id),
|
||||
@@ -129,7 +125,8 @@ pub enum CanvasEvent {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScrollArea {
|
||||
// The independent Details/outline scroller; viewport motion uses Camera.
|
||||
Canvas,
|
||||
// Retained navigation channel, now the independent Selection/outline shelf.
|
||||
Page,
|
||||
}
|
||||
impl CanvasEvent {
|
||||
@@ -138,9 +135,7 @@ impl CanvasEvent {
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
@@ -167,7 +162,6 @@ impl std::fmt::Display for Operator {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -203,14 +197,11 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
form_session: Id,
|
||||
form_mount: Id,
|
||||
form_focus_pending: bool,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||||
details_offset: iced::Vector,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<Caller>,
|
||||
restore: Option<DetailsRestore>,
|
||||
restore: Option<ViewportRestore>,
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
const CALLER_LIMIT: usize = 32;
|
||||
@@ -220,14 +211,15 @@ pub(crate) struct Caller {
|
||||
pub occurrence: Id,
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
details_offset: iced::Vector,
|
||||
offsets: [iced::Vector; 2],
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
struct DetailsRestore {
|
||||
struct ViewportRestore {
|
||||
document: Id,
|
||||
owner: Id,
|
||||
token: Id,
|
||||
offset: iced::Vector,
|
||||
offsets: [iced::Vector; 2],
|
||||
pending: [bool; 2],
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ScrollRestore {
|
||||
@@ -248,13 +240,6 @@ struct CallChoiceContext {
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
@@ -287,44 +272,6 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_tool,
|
||||
self.presentation.focused,
|
||||
self.presentation.shelf,
|
||||
self.presentation.viewport,
|
||||
self.presentation.pan,
|
||||
self.presentation.zoom,
|
||||
self.presentation.fit,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
returned
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
@@ -332,38 +279,16 @@ impl State {
|
||||
focused: self.presentation.focused,
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
}
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
|
||||
let p = &mut self.presentation;
|
||||
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
||||
let size = Scene::sequence(p, owner, owner).size;
|
||||
let old = self.presentation.scale(size);
|
||||
if let Some(next) = zoom {
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: old,
|
||||
})
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.presentation.scale(size);
|
||||
self.zoom(Some(fitted));
|
||||
self.presentation.pan = iced::Vector::ZERO;
|
||||
true
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
self.presentation.focused
|
||||
}
|
||||
@@ -392,11 +317,11 @@ impl State {
|
||||
occurrence,
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
details_offset: self.details_offset,
|
||||
offsets: self.scroll_offsets,
|
||||
presentation: self.presentation.clone(),
|
||||
});
|
||||
self.canvas_tool = Some((target, CanvasTool::Select));
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
Ok(target)
|
||||
}
|
||||
@@ -423,30 +348,34 @@ impl State {
|
||||
return;
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.details_offset = frame.details_offset;
|
||||
self.scroll_offsets = frame.offsets;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.restore = Some(DetailsRestore {
|
||||
self.restore = Some(ViewportRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
token: Id::new_v4(),
|
||||
offset: frame.details_offset,
|
||||
offsets: frame.offsets,
|
||||
pending: [true; 2],
|
||||
});
|
||||
}
|
||||
pub(super) fn scroll_restore(
|
||||
&self,
|
||||
context: Context,
|
||||
ScrollArea::Page: ScrollArea,
|
||||
area: ScrollArea,
|
||||
) -> Option<ScrollRestore> {
|
||||
let restore = self
|
||||
.restore
|
||||
.as_ref()
|
||||
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let restore = self.restore.as_ref().filter(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.pending[index]
|
||||
})?;
|
||||
Some(ScrollRestore {
|
||||
document: restore.document,
|
||||
owner: restore.owner,
|
||||
token: restore.token,
|
||||
offset: restore.offset,
|
||||
offset: restore.offsets[index],
|
||||
})
|
||||
}
|
||||
pub(crate) fn restored(
|
||||
@@ -456,12 +385,22 @@ impl State {
|
||||
area: ScrollArea,
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !self.restore.as_ref().is_some_and(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.token == token
|
||||
}) {
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let Some(restore) = self.restore.as_mut().filter(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.token == token
|
||||
&& r.pending[index]
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
restore.pending[index] = false;
|
||||
if !restore.pending.contains(&true) {
|
||||
self.restore = None;
|
||||
}
|
||||
self.restore = None;
|
||||
self.scrolled(area, offset)
|
||||
}
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
@@ -474,57 +413,28 @@ impl State {
|
||||
|
||||
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
||||
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
let session = Id::new_v4();
|
||||
Ok(Self {
|
||||
draft: Some(draft),
|
||||
form_session: session,
|
||||
form_mount: session,
|
||||
form_focus_pending: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn form_session(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|_| self.form_session)
|
||||
}
|
||||
|
||||
pub(crate) fn form_mount(&self) -> Option<Id> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.filter(|d| !d.source.is_open())
|
||||
.map(|_| self.form_mount)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
|
||||
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
|
||||
context,
|
||||
self.form_session,
|
||||
self.form_mount()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn remount_form(&mut self) {
|
||||
self.form_mount = Id::new_v4();
|
||||
self.form_focus_pending = true;
|
||||
}
|
||||
|
||||
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
|
||||
if self.form_mount() == Some(mount) && self.form_focus_pending {
|
||||
self.form_focus_pending = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
|
||||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
}
|
||||
let changed = self.details_offset != offset;
|
||||
self.details_offset = offset;
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
// The former page scroll now belongs to a sibling outline shelf.
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
|
||||
let key = CallChoiceContext {
|
||||
document: context.document,
|
||||
@@ -542,16 +452,8 @@ impl State {
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let draft = self.draft.as_ref()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||
match command {
|
||||
Command::CreateElement(element) if element.parent == Some(parent) => {
|
||||
@@ -666,66 +568,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
self.remount_form();
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Lifeline {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: d.owner,
|
||||
viewpoint: viewpoint(p, d.owner),
|
||||
},
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
let returned = d.source.is_open() && !next.source.is_open();
|
||||
self.draft = Some(next);
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -734,13 +576,7 @@ impl State {
|
||||
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.form_session = Id::new_v4();
|
||||
self.form_mount = self.form_session;
|
||||
self.form_focus_pending = true;
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
owner,
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -1104,14 +940,7 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
|
||||
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
|
||||
}
|
||||
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&d.owner)
|
||||
.filter(|e| e.kind() == Kind::Scenario)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let layer = owner.layer();
|
||||
let layer = p.elements[&d.owner].layer();
|
||||
let mut values: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1427,28 +1256,6 @@ impl Draft {
|
||||
e
|
||||
}
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: self.owner,
|
||||
viewpoint: viewpoint(p, self.owner),
|
||||
},
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
|
||||
}
|
||||
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
|
||||
return Err("Choose this scenario or one of its operands as destination.".into());
|
||||
}
|
||||
@@ -1459,8 +1266,6 @@ impl Draft {
|
||||
.unwrap_or(self.tool.label());
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -2052,31 +1857,20 @@ pub(crate) mod direct_tests {
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
let camera_epoch = app.camera_epoch;
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Camera(
|
||||
ctx,
|
||||
camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
|
||||
)
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
|
||||
assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
let camera_epoch = app.camera_epoch;
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Camera(
|
||||
ctx,
|
||||
camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
|
||||
)
|
||||
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
|
||||
assert_eq!(app.selected, c);
|
||||
@@ -2087,7 +1881,7 @@ pub(crate) mod direct_tests {
|
||||
CanvasTool::Message(MessageStyle::Timer)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(0., 115.)
|
||||
);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
@@ -2098,11 +1892,11 @@ pub(crate) mod direct_tests {
|
||||
CanvasTool::Add(Tool::Reuse)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 170.)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
app.behavior.scenario.scroll_offsets[1],
|
||||
iced::Vector::new(0., 80.),
|
||||
"The caller's independent outline scroll is restored separately"
|
||||
);
|
||||
@@ -2280,24 +2074,22 @@ pub(crate) mod direct_tests {
|
||||
let before = app.document.bundle().clone();
|
||||
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
||||
scene.interaction = Some((context, CanvasTool::Add(tool), true));
|
||||
scene.camera_pan = iced::Vector::new(23., 80.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(20., 40.),
|
||||
Size::new(scene.size.width + 46., scene.size.height + 160.),
|
||||
);
|
||||
scene.scroll_offset = iced::Vector::new(23., 80.);
|
||||
let bounds = Rectangle::new(Point::new(20., 40.), scene.size);
|
||||
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
|
||||
let x = if on_lifeline {
|
||||
scene.lanes[&f.sender]
|
||||
} else {
|
||||
scene.size.width - 35.
|
||||
};
|
||||
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
|
||||
assert!(bounds.contains(point));
|
||||
let point = Point::new(bounds.x + x, bounds.y + y);
|
||||
let mut input = SequenceInput::default();
|
||||
canvas_mouse(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
mouse::Event::CursorMoved {
|
||||
position: point - scene.scroll_offset,
|
||||
},
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
@@ -2473,10 +2265,10 @@ pub(crate) mod direct_tests {
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
let old_context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
old_context,
|
||||
app.camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
|
||||
ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 100.),
|
||||
)));
|
||||
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
@@ -2667,29 +2459,26 @@ pub(crate) mod direct_tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
|
||||
fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() {
|
||||
let f = fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
|
||||
)));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
)));
|
||||
for (area, offset) in [
|
||||
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
|
||||
(ScrollArea::Page, iced::Vector::new(0., 40.)),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
assert!(app.busy);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
);
|
||||
assert_ne!(
|
||||
app.scenario_context(),
|
||||
@@ -2700,14 +2489,14 @@ pub(crate) mod direct_tests {
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
ScrollArea::Page,
|
||||
ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.details_offset,
|
||||
iced::Vector::new(0., 40.)
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
);
|
||||
let root = std::path::PathBuf::from("scenario-details-save-fixture");
|
||||
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
|
||||
let receipt = crate::app::FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
@@ -2728,17 +2517,12 @@ pub(crate) mod direct_tests {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
||||
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.pan,
|
||||
iced::Vector::new(-25., -100.)
|
||||
);
|
||||
scene.camera_pan = app.behavior.scenario.presentation.pan;
|
||||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||||
let slot = scene.slots.last().unwrap().slot;
|
||||
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
|
||||
let y = bounds.y + scene.slots.last().unwrap().y;
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset;
|
||||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
|
||||
let mut input = super::super::SequenceInput::default();
|
||||
let mut published = vec![];
|
||||
for event in [
|
||||
@@ -2752,7 +2536,7 @@ pub(crate) mod direct_tests {
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(b),
|
||||
mouse::Cursor::Available(b + scene.scroll_offset),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
{
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
//! A delayed focus request must still own the live parent tree when executed.
|
||||
//! Collect first: a missing target must not unfocus unrelated fields.
|
||||
use super::Context;
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
|
||||
use oa_core::Id as ModelId;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Stamp {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
}
|
||||
impl Stamp {
|
||||
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
))
|
||||
}
|
||||
struct Owner(Option<Stamp>);
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
owner: Option<Stamp>,
|
||||
}
|
||||
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Stamp>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
content: content.into(),
|
||||
owner,
|
||||
})
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
op: &mut dyn Operation,
|
||||
) {
|
||||
// This is computed from the current App view, never retained widget state.
|
||||
op.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, op);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Collect {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending: Option<Scroll>,
|
||||
index: usize,
|
||||
target: Option<(Rectangle, Vec<Scroll>)>,
|
||||
}
|
||||
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
stamp,
|
||||
valid: false,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending: None,
|
||||
index: 0,
|
||||
target: None,
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.scrolls.len());
|
||||
if let Some(scroll) = self.pending.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.translation = previous.0;
|
||||
self.scrolls.truncate(previous.1);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending = Some(Scroll {
|
||||
index: self.index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.index += 1;
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.valid {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let Some((mut bounds, ancestors)) = self.target.clone() else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in ancestors.iter().rev() {
|
||||
let dx = reveal(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
// Drawing offsets are rounded; an already-visible axis must not
|
||||
// acquire a scroll delta from a fractional capacity alone.
|
||||
let delta = Vector::new(
|
||||
if dx == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
|
||||
},
|
||||
if dy == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
|
||||
},
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
stamp: self.stamp,
|
||||
valid: false,
|
||||
shifts,
|
||||
index: 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
|
||||
if start < viewport || length > size {
|
||||
start - viewport
|
||||
} else {
|
||||
(start + length - viewport - size).max(0.)
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
index: usize,
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.valid {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& let Some(delta) = self.shifts.get(&self.index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,285 +0,0 @@
|
||||
//! Read-only lifecycle geometry. Operand screen order is never execution order.
|
||||
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(super) mod render;
|
||||
|
||||
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
|
||||
pub(crate) struct Geometry<'a> {
|
||||
pub rows: &'a BTreeMap<Id, (Id, f32)>,
|
||||
pub scopes: &'a BTreeMap<Id, (f32, f32)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Span {
|
||||
pub start: f32,
|
||||
pub end: f32,
|
||||
}
|
||||
impl Span {
|
||||
fn valid(self) -> bool {
|
||||
self.start.is_finite() && self.end.is_finite() && self.start <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Life {
|
||||
Alive,
|
||||
Absent,
|
||||
Unresolved,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Interval {
|
||||
pub scope: Id,
|
||||
pub span: Span,
|
||||
pub life: Life,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MarkerKind {
|
||||
Birth,
|
||||
Death,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Marker {
|
||||
pub occurrence: Id,
|
||||
pub scope: Id,
|
||||
pub y: f32,
|
||||
pub kind: MarkerKind,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UncertaintyReason {
|
||||
Conditional,
|
||||
Repeated,
|
||||
Parallel,
|
||||
MissingGeometry,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Uncertainty {
|
||||
pub scope: Id,
|
||||
pub reason: UncertaintyReason,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct LaneLifetime {
|
||||
pub lifeline: Id,
|
||||
pub intervals: Vec<Interval>,
|
||||
pub markers: Vec<Marker>,
|
||||
pub uncertainties: Vec<Uncertainty>,
|
||||
}
|
||||
|
||||
/// Stable lane-ID order; source/model/history and caller-owned geometry are untouched.
|
||||
/// An opaque ScenarioUse is a local use, not a caller birth/death contract.
|
||||
pub(crate) fn project_lifetimes(
|
||||
project: &Project,
|
||||
scenario: Id,
|
||||
geometry: &Geometry<'_>,
|
||||
) -> Vec<LaneLifetime> {
|
||||
if project
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_none_or(|e| e.kind() != Kind::Scenario)
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Lifeline && e.parent == Some(scenario))
|
||||
.map(|e| {
|
||||
let mut projection = Projection {
|
||||
project,
|
||||
geometry,
|
||||
lane: e.id,
|
||||
visiting: BTreeSet::new(),
|
||||
result: LaneLifetime {
|
||||
lifeline: e.id,
|
||||
intervals: vec![],
|
||||
markers: vec![],
|
||||
uncertainties: vec![],
|
||||
},
|
||||
};
|
||||
let initial = if projection.contains(scenario, Some(MarkerKind::Birth)) {
|
||||
Life::Unresolved
|
||||
} else {
|
||||
Life::Alive
|
||||
};
|
||||
projection.sequence(scenario, initial);
|
||||
projection.result
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Projection<'a, 'g> {
|
||||
project: &'a Project,
|
||||
geometry: &'a Geometry<'g>,
|
||||
lane: Id,
|
||||
visiting: BTreeSet<Id>,
|
||||
result: LaneLifetime,
|
||||
}
|
||||
impl Projection<'_, '_> {
|
||||
fn body(&self, id: Id) -> Option<&OccurrenceBody> {
|
||||
match self.project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn event(&self, id: Id) -> Option<MarkerKind> {
|
||||
match self.body(id)? {
|
||||
OccurrenceBody::Exchange {
|
||||
receiver: Some(line),
|
||||
message_kind,
|
||||
..
|
||||
} if *line == self.lane => match message_kind {
|
||||
MessageKind::Create => Some(MarkerKind::Birth),
|
||||
MessageKind::Delete => Some(MarkerKind::Death),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn contains(&self, scope: Id, kind: Option<MarkerKind>) -> bool {
|
||||
// Local structural walk only: no guards, iteration simulation or reuse expansion.
|
||||
let mut pending = vec![scope];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.event(id)
|
||||
.is_some_and(|event| kind.is_none_or(|k| event == k))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(steps) = self.project.elements.get(&id).and_then(|e| e.steps()) {
|
||||
pending.extend(steps);
|
||||
}
|
||||
if let Some(OccurrenceBody::Fragment { branches, .. }) = self.body(id) {
|
||||
pending.extend(branches);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
fn span(&self, scope: Id) -> Option<Span> {
|
||||
let &(start, end) = self.geometry.scopes.get(&scope)?;
|
||||
let span = Span { start, end };
|
||||
span.valid().then_some(span)
|
||||
}
|
||||
fn uncertain(&mut self, scope: Id, reason: UncertaintyReason) {
|
||||
let value = Uncertainty { scope, reason };
|
||||
if !self.result.uncertainties.contains(&value) {
|
||||
self.result.uncertainties.push(value);
|
||||
}
|
||||
}
|
||||
fn interval(&mut self, scope: Id, start: f32, end: f32, life: Life) {
|
||||
if start < end && start.is_finite() && end.is_finite() {
|
||||
self.result.intervals.push(Interval {
|
||||
scope,
|
||||
span: Span { start, end },
|
||||
life,
|
||||
});
|
||||
}
|
||||
}
|
||||
fn sequence(&mut self, scope: Id, entry: Life) -> Life {
|
||||
let Some(span) = self.span(scope) else {
|
||||
self.uncertain(scope, UncertaintyReason::MissingGeometry);
|
||||
return Life::Unresolved;
|
||||
};
|
||||
if !self.visiting.insert(scope) {
|
||||
self.uncertain(scope, UncertaintyReason::MissingGeometry);
|
||||
return Life::Unresolved;
|
||||
}
|
||||
let steps = self
|
||||
.project
|
||||
.elements
|
||||
.get(&scope)
|
||||
.and_then(|e| e.steps())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// A creation directly in this sequence fixes its local birth boundary.
|
||||
// A creation only in a nested operand cannot fix its ancestor's entry.
|
||||
let mut life = if steps
|
||||
.iter()
|
||||
.any(|&id| self.event(id) == Some(MarkerKind::Birth))
|
||||
{
|
||||
Life::Absent
|
||||
} else {
|
||||
entry
|
||||
};
|
||||
let mut cursor = span.start;
|
||||
for id in steps {
|
||||
let event = self.event(id);
|
||||
let fragment = match self.body(id) {
|
||||
Some(OccurrenceBody::Fragment { operator, branches }) => {
|
||||
Some((*operator, branches.clone()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if event.is_none() && fragment.is_none() {
|
||||
continue;
|
||||
}
|
||||
let y = self
|
||||
.geometry
|
||||
.rows
|
||||
.get(&id)
|
||||
.filter(|(owner, y)| {
|
||||
*owner == scope && y.is_finite() && *y >= cursor && *y <= span.end
|
||||
})
|
||||
.map(|(_, y)| *y);
|
||||
let Some(y) = y else {
|
||||
self.uncertain(id, UncertaintyReason::MissingGeometry);
|
||||
life = Life::Unresolved;
|
||||
continue;
|
||||
};
|
||||
self.interval(scope, cursor, y, life);
|
||||
cursor = y;
|
||||
if let Some(kind) = event {
|
||||
self.result.markers.push(Marker {
|
||||
occurrence: id,
|
||||
scope,
|
||||
y,
|
||||
kind,
|
||||
});
|
||||
life = match kind {
|
||||
MarkerKind::Birth => Life::Alive,
|
||||
MarkerKind::Death => Life::Absent,
|
||||
};
|
||||
}
|
||||
if let Some((operator, branches)) = fragment {
|
||||
let entry = life;
|
||||
let mut missing = false;
|
||||
for branch in branches {
|
||||
let bounds = self
|
||||
.span(branch)
|
||||
.filter(|s| s.start >= cursor && s.end <= span.end);
|
||||
if let Some(bounds) = bounds {
|
||||
// Every sibling inherits the same entry, never its displayed predecessor's exit.
|
||||
self.sequence(branch, entry);
|
||||
cursor = bounds.end;
|
||||
} else {
|
||||
self.uncertain(branch, UncertaintyReason::MissingGeometry);
|
||||
missing = true;
|
||||
}
|
||||
}
|
||||
if self.contains(id, None) {
|
||||
let reason = match operator {
|
||||
FragmentOperator::Alternative | FragmentOperator::Optional => {
|
||||
UncertaintyReason::Conditional
|
||||
}
|
||||
FragmentOperator::Loop => UncertaintyReason::Repeated,
|
||||
FragmentOperator::Parallel => UncertaintyReason::Parallel,
|
||||
};
|
||||
self.uncertain(id, reason);
|
||||
life = Life::Unresolved;
|
||||
} else if missing {
|
||||
life = Life::Unresolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.interval(scope, cursor, span.end, life);
|
||||
self.visiting.remove(&scope);
|
||||
life
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_lifecycle/tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,392 +0,0 @@
|
||||
//! Scene bridge for local lifetime geometry; no authoring or model mutations.
|
||||
use super::{Geometry, LaneLifetime, Life, Marker, MarkerKind, Span, project_lifetimes};
|
||||
use crate::{behavior::Line, style, views::short};
|
||||
use iced::{
|
||||
Color, Point, Rectangle, Size,
|
||||
widget::canvas::{self, Frame, Path, Stroke, Text},
|
||||
};
|
||||
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct EventMarker {
|
||||
event: Marker,
|
||||
lifeline: Id,
|
||||
center: Point,
|
||||
capsule: Rectangle,
|
||||
name: String,
|
||||
sender_x: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub(in crate::behavior) struct Rendering {
|
||||
// Complete rendering positions, including warning-admitted unassigned lanes.
|
||||
// Scene.lanes independently retains the existing authoring eligibility rule.
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
span: Option<Span>,
|
||||
lifetimes: Vec<LaneLifetime>,
|
||||
markers: Vec<EventMarker>,
|
||||
}
|
||||
|
||||
impl Rendering {
|
||||
pub(in crate::behavior) fn new(
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
rows: &BTreeMap<Id, (Id, f32)>,
|
||||
insertion_scopes: &BTreeMap<Id, (f32, f32)>,
|
||||
height: f32,
|
||||
) -> Self {
|
||||
let span = Span {
|
||||
start: 85.,
|
||||
end: height - 20.,
|
||||
};
|
||||
if !span.valid() {
|
||||
return Self::default();
|
||||
}
|
||||
// Insertion scopes deliberately start later and end earlier. Empty and
|
||||
// Create-only scenarios still have the full visible lifetime extent.
|
||||
let mut scopes = insertion_scopes.clone();
|
||||
for (scope, (start, end)) in &mut scopes {
|
||||
if *scope != owner && *start == *end && start.is_finite() {
|
||||
// An empty operand has 42 px between its insertion axis and
|
||||
// frame end. Match the existing 26 px open-activation allowance.
|
||||
*end = *start + 26.;
|
||||
}
|
||||
}
|
||||
scopes.insert(owner, (span.start, span.end));
|
||||
let lifetimes = project_lifetimes(
|
||||
project,
|
||||
owner,
|
||||
&Geometry {
|
||||
rows,
|
||||
scopes: &scopes,
|
||||
},
|
||||
);
|
||||
let lanes: BTreeMap<_, _> = lanes
|
||||
.iter()
|
||||
.filter(|(_, x)| x.is_finite())
|
||||
.map(|(id, x)| (*id, *x))
|
||||
.collect();
|
||||
let mut markers = Vec::new();
|
||||
for lane in &lifetimes {
|
||||
let Some(&x) = lanes.get(&lane.lifeline) else {
|
||||
continue;
|
||||
};
|
||||
for event in &lane.markers {
|
||||
let sender_x = project
|
||||
.elements
|
||||
.get(&event.occurrence)
|
||||
.and_then(|e| e.shared())
|
||||
.and_then(|body| match body {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender: Some(sender),
|
||||
..
|
||||
},
|
||||
..
|
||||
} => lanes.get(sender).copied(),
|
||||
_ => None,
|
||||
});
|
||||
markers.push(EventMarker {
|
||||
event: *event,
|
||||
lifeline: lane.lifeline,
|
||||
center: Point::new(x, event.y),
|
||||
capsule: Rectangle::new(
|
||||
Point::new(x - 70., event.y - 14.),
|
||||
Size::new(140., 28.),
|
||||
),
|
||||
name: project.named(lane.lifeline).to_owned(),
|
||||
sender_x,
|
||||
});
|
||||
}
|
||||
}
|
||||
Self {
|
||||
lanes,
|
||||
span: Some(span),
|
||||
lifetimes,
|
||||
markers,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn align_messages(&self, lines: &mut [Line]) {
|
||||
for marker in &self.markers {
|
||||
let Some(sender) = marker.sender_x else {
|
||||
continue;
|
||||
};
|
||||
let Some(line) = lines
|
||||
.iter_mut()
|
||||
.find(|line| line.id == marker.event.occurrence)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let x = marker.center.x;
|
||||
let y = marker.center.y;
|
||||
let self_message = sender == x;
|
||||
let end_x = match marker.event.kind {
|
||||
MarkerKind::Birth if sender < x => marker.capsule.x,
|
||||
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
|
||||
MarkerKind::Death => x,
|
||||
};
|
||||
line.points = if self_message {
|
||||
// The receiver is on the recorded occurrence row, including self
|
||||
// lifecycle events. The elbow stays outside the birth capsule.
|
||||
let elbow = x + 112.;
|
||||
line.label_at = Point::new(x + 12., y - 52.);
|
||||
vec![
|
||||
Point::new(x, y - 24.),
|
||||
Point::new(elbow, y - 24.),
|
||||
Point::new(elbow, y),
|
||||
Point::new(end_x, y),
|
||||
]
|
||||
} else {
|
||||
if marker.event.kind == MarkerKind::Birth {
|
||||
// Keep the label above the capsule, including right-to-left
|
||||
// messages whose existing label starts beside the receiver.
|
||||
line.label_at.y = y - 40.;
|
||||
}
|
||||
vec![Point::new(sender, y), Point::new(end_x, y)]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn alive_spans(
|
||||
&self,
|
||||
lifeline: Id,
|
||||
scope: Id,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
) -> Vec<Span> {
|
||||
if !top.is_finite() || !bottom.is_finite() || bottom <= top {
|
||||
return vec![];
|
||||
}
|
||||
self.lifetimes
|
||||
.iter()
|
||||
.find(|lane| lane.lifeline == lifeline)
|
||||
.into_iter()
|
||||
.flat_map(|lane| &lane.intervals)
|
||||
.filter(|interval| interval.scope == scope && interval.life == Life::Alive)
|
||||
.filter_map(|interval| {
|
||||
let start = top.max(interval.span.start);
|
||||
let end = bottom.min(interval.span.end);
|
||||
(start < end).then_some(Span { start, end })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
|
||||
self.markers
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|marker| match marker.event.kind {
|
||||
MarkerKind::Birth => marker.capsule.contains(point),
|
||||
MarkerKind::Death => marker_distance(marker, point) <= 1.25,
|
||||
})
|
||||
.map(|marker| marker.event.occurrence)
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn nearest_marker(&self, point: Point) -> Option<(Id, f32)> {
|
||||
if !point.x.is_finite() || !point.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Exact visible shapes are tested first by Scene. A low-zoom tolerance
|
||||
// must not outrank another marker's actual capsule, a header, or a label.
|
||||
self.markers
|
||||
.iter()
|
||||
.map(|marker| (marker.event.occurrence, marker_distance(marker, point)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn lane_at(&self, point: Point, scale: f32) -> Option<Id> {
|
||||
let span = self.span?;
|
||||
if !scale.is_finite() || scale <= 0. || point.y < span.start || point.y > span.end {
|
||||
return None;
|
||||
}
|
||||
self.lanes
|
||||
.iter()
|
||||
.filter(|(_, x)| (**x - point.x).abs() < 12. / scale)
|
||||
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn draw_lanes(&self, frame: &mut Frame, selected: Id) {
|
||||
let Some(span) = self.span else {
|
||||
return;
|
||||
};
|
||||
for (&id, &x) in &self.lanes {
|
||||
frame.stroke(
|
||||
&Path::line(Point::new(x, span.start), Point::new(x, span.end)),
|
||||
Stroke {
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[2., 5.],
|
||||
offset: 0,
|
||||
},
|
||||
..Stroke::default()
|
||||
.with_width(1.)
|
||||
.with_color(if id == selected {
|
||||
Color::from_rgb8(148, 190, 187)
|
||||
} else {
|
||||
Color::from_rgb8(214, 222, 222)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
for lifetime in &self.lifetimes {
|
||||
let Some(&x) = self.lanes.get(&lifetime.lifeline) else {
|
||||
continue;
|
||||
};
|
||||
for interval in &lifetime.intervals {
|
||||
match interval.life {
|
||||
Life::Alive => frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(x, interval.span.start),
|
||||
Point::new(x, interval.span.end),
|
||||
),
|
||||
Stroke::default().with_width(2.).with_color(
|
||||
if lifetime.lifeline == selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(91, 113, 113)
|
||||
},
|
||||
),
|
||||
),
|
||||
Life::Unresolved if interval.span.end - interval.span.start >= 16. => {
|
||||
frame.fill_text(Text {
|
||||
content: "?".into(),
|
||||
position: Point::new(x + 9., interval.span.start + 2.),
|
||||
size: 11.into(),
|
||||
color: style::MUTED,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.lanes.is_empty() {
|
||||
frame.fill_text(Text {
|
||||
content:
|
||||
"Dotted: editing guide · Solid: local lifetime · ?: lifetime depends on path"
|
||||
.into(),
|
||||
position: Point::new(28., span.end + 4.),
|
||||
size: 10.into(),
|
||||
color: style::MUTED,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn draw_markers(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
pan: iced::Vector,
|
||||
) {
|
||||
for marker in &self.markers {
|
||||
let color = if selected == marker.event.occurrence || selected == marker.lifeline {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
};
|
||||
match marker.event.kind {
|
||||
MarkerKind::Birth => {
|
||||
let path = Path::rounded_rectangle(
|
||||
marker.capsule.position(),
|
||||
marker.capsule.size(),
|
||||
6.into(),
|
||||
);
|
||||
frame.fill(&path, Color::from_rgb8(243, 248, 248));
|
||||
frame.stroke(
|
||||
&path,
|
||||
Stroke::default().with_color(color).with_width(
|
||||
if selected == marker.event.occurrence {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
},
|
||||
),
|
||||
);
|
||||
if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
|
||||
// WGPU clipped frames start with an identity transform;
|
||||
// neither the parent's pan/scale nor its clip is inherited.
|
||||
frame.with_clip(clip, |label| {
|
||||
label.translate(pan);
|
||||
label.scale(scale);
|
||||
label.fill_text(Text {
|
||||
content: short(&marker.name, 18),
|
||||
position: marker.capsule.position() + iced::Vector::new(8., 6.),
|
||||
size: 12.into(),
|
||||
color,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
MarkerKind::Death => {
|
||||
for (a, b) in death_strokes(marker.center) {
|
||||
frame.stroke(
|
||||
&Path::line(a, b),
|
||||
Stroke::default().with_color(color).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn birth_label_clip(
|
||||
pan: iced::Vector,
|
||||
capsule: Rectangle,
|
||||
scale: f32,
|
||||
viewport: Size,
|
||||
) -> Option<Rectangle> {
|
||||
if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
pan.x + (capsule.x + 8.) * scale,
|
||||
pan.y + (capsule.y + 4.) * scale,
|
||||
),
|
||||
Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
|
||||
)
|
||||
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
|
||||
}
|
||||
|
||||
fn marker_distance(marker: &EventMarker, point: Point) -> f32 {
|
||||
match marker.event.kind {
|
||||
MarkerKind::Birth => {
|
||||
let dx = (marker.capsule.x - point.x)
|
||||
.max(point.x - marker.capsule.x - marker.capsule.width)
|
||||
.max(0.);
|
||||
let dy = (marker.capsule.y - point.y)
|
||||
.max(point.y - marker.capsule.y - marker.capsule.height)
|
||||
.max(0.);
|
||||
dx.hypot(dy)
|
||||
}
|
||||
MarkerKind::Death => death_strokes(marker.center)
|
||||
.iter()
|
||||
.map(|(a, b)| crate::diagram::distance_to_segment(point, *a, *b))
|
||||
.fold(f32::INFINITY, f32::min),
|
||||
}
|
||||
}
|
||||
|
||||
fn death_strokes(center: Point) -> [(Point, Point); 2] {
|
||||
[
|
||||
(
|
||||
center + iced::Vector::new(-7., -7.),
|
||||
center + iced::Vector::new(7., 7.),
|
||||
),
|
||||
(
|
||||
center + iced::Vector::new(-7., 7.),
|
||||
center + iced::Vector::new(7., -7.),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,973 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{behavior::Scene, document::Document};
|
||||
use iced::Size;
|
||||
use oa_core::*;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
sender: Id,
|
||||
receiver: Id,
|
||||
work: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new(layer: Layer) -> Self {
|
||||
let mut project = Project::empty();
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let a = project.add_element(kind, layer, "Request", None).unwrap();
|
||||
let work = project.add_element(kind, layer, "Provide", None).unwrap();
|
||||
let exchange = project.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Service",
|
||||
a,
|
||||
work,
|
||||
);
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, layer, "Lifecycle", None)
|
||||
.unwrap();
|
||||
let mut lane = |name, represented| {
|
||||
let id = project
|
||||
.add_element(Kind::Lifeline, layer, name, Some(owner))
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
id
|
||||
};
|
||||
let sender = lane("Requester", a);
|
||||
let receiver = lane("Provider", work);
|
||||
Self {
|
||||
project,
|
||||
owner,
|
||||
sender,
|
||||
receiver,
|
||||
work,
|
||||
exchange,
|
||||
}
|
||||
}
|
||||
fn occurrence(&mut self, parent: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let mut element = self
|
||||
.project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
self.project.elements[&parent].layer(),
|
||||
name,
|
||||
Some(parent),
|
||||
)
|
||||
.unwrap();
|
||||
let id = element.id;
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
self.project.elements.insert(id, element);
|
||||
let parent = self.project.elements.get_mut(&parent).unwrap();
|
||||
if let Some(steps) = parent.steps_mut() {
|
||||
steps.push(id);
|
||||
} else if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = parent.shared_mut()
|
||||
{
|
||||
branches.push(id);
|
||||
} else {
|
||||
panic!("fixture parent must own ordered occurrences or operands");
|
||||
}
|
||||
id
|
||||
}
|
||||
fn message(&mut self, parent: Id, kind: MessageKind) -> Id {
|
||||
self.occurrence(
|
||||
parent,
|
||||
"Message",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(self.exchange),
|
||||
sender: Some(self.sender),
|
||||
receiver: Some(self.receiver),
|
||||
message_kind: kind,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn fragment(&mut self, parent: Id, operator: FragmentOperator) -> Id {
|
||||
self.occurrence(
|
||||
parent,
|
||||
"Paths",
|
||||
OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn branch(&mut self, fragment: Id) -> Id {
|
||||
self.occurrence(
|
||||
fragment,
|
||||
"Operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "condition".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn document(&self) -> Document {
|
||||
validate_candidate(&self.project).expect("ordinary core admission must succeed");
|
||||
let mut bundle = source_project::format_bundle(&self.project);
|
||||
for text in bundle.files.values_mut() {
|
||||
*text = format!(
|
||||
"// Retain exact authored CRLF and Unicode Ω\r\n{}",
|
||||
text.replace('\n', "\r\n")
|
||||
);
|
||||
}
|
||||
let document =
|
||||
Document::from_bundle(bundle.clone(), source_project::ProjectLimits::default())
|
||||
.unwrap();
|
||||
assert_eq!(document.project(), &self.project);
|
||||
assert_eq!(document.bundle(), &bundle);
|
||||
document
|
||||
}
|
||||
fn scene(&self) -> Scene {
|
||||
let document = self.document();
|
||||
let before = document.snapshot();
|
||||
let scene = Scene::sequence(document.project(), self.owner, self.owner);
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert_eq!(document.revision(), 0);
|
||||
assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
|
||||
assert_eq!(
|
||||
scene.lifecycle,
|
||||
Scene::sequence(document.project(), self.owner, self.owner).lifecycle
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.points)
|
||||
.all(|p| p.x.is_finite() && p.y.is_finite())
|
||||
);
|
||||
scene
|
||||
}
|
||||
}
|
||||
|
||||
fn intervals(scene: &Scene, lifeline: Id, scope: Id) -> Vec<(f32, f32, Life)> {
|
||||
scene
|
||||
.lifecycle
|
||||
.lifetimes
|
||||
.iter()
|
||||
.find(|lane| lane.lifeline == lifeline)
|
||||
.unwrap()
|
||||
.intervals
|
||||
.iter()
|
||||
.filter(|i| i.scope == scope)
|
||||
.map(|i| (i.span.start, i.span.end, i.life))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for name in [
|
||||
"W".repeat(18),
|
||||
"界".repeat(24),
|
||||
"e\u{301}".repeat(24),
|
||||
"👩\u{200d}🔧".repeat(12),
|
||||
] {
|
||||
let mut f = Fixture::new(layer);
|
||||
f.project.elements.get_mut(&f.receiver).unwrap().name = name.clone();
|
||||
f.project.elements.get_mut(&f.work).unwrap().name = name.clone();
|
||||
let scene = f.scene();
|
||||
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
|
||||
assert_eq!(header.title, format!("Lane · {name}"));
|
||||
assert_eq!(header.detail, name);
|
||||
assert_eq!(header.rect.size(), Size::new(204., 70.));
|
||||
assert_eq!(scene.hit(header.rect.center()), Some(f.receiver));
|
||||
// Scroll offset is handled by the existing outer canvas/scrollable;
|
||||
// these are the local drawing frame's coordinates, not screen input.
|
||||
for scale in [0.05, 0.5, 0.512, 0.64, 0.8, 1., 1.953125, 2.] {
|
||||
let clip = crate::behavior::box_text_clip(
|
||||
iced::Vector::ZERO,
|
||||
header.rect,
|
||||
scale,
|
||||
Size::new(2000., 2000.),
|
||||
)
|
||||
.unwrap();
|
||||
let world = Rectangle::new(
|
||||
Point::new(clip.x / scale, clip.y / scale),
|
||||
Size::new(clip.width / scale, clip.height / scale),
|
||||
);
|
||||
assert!((world.x - header.rect.x - 10.).abs() < 0.001);
|
||||
assert!((world.y - header.rect.y - 8.).abs() < 0.001);
|
||||
assert!((world.width - 184.).abs() < 0.001);
|
||||
assert!((world.height - 54.).abs() < 0.001);
|
||||
assert!(header.rect.contains(world.position()));
|
||||
assert!(
|
||||
header
|
||||
.rect
|
||||
.contains(Point::new(world.x + world.width, world.y + world.height,))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() {
|
||||
use crate::behavior::box_text_clip;
|
||||
let card = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
|
||||
assert_eq!(
|
||||
box_text_clip(iced::Vector::ZERO, card, 2., Size::new(300., 500.)),
|
||||
Some(Rectangle::new(Point::new(220., 416.), Size::new(80., 84.)))
|
||||
);
|
||||
assert_eq!(
|
||||
box_text_clip(
|
||||
iced::Vector::ZERO,
|
||||
Rectangle::new(Point::new(-20., -10.), card.size()),
|
||||
2.,
|
||||
Size::new(300., 500.),
|
||||
),
|
||||
Some(Rectangle::new(Point::ORIGIN, Size::new(300., 104.)))
|
||||
);
|
||||
for viewport in [Size::new(200., 500.), Size::new(300., 400.), Size::ZERO] {
|
||||
assert_eq!(box_text_clip(iced::Vector::ZERO, card, 2., viewport), None);
|
||||
}
|
||||
for scale in [0., -1., f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(
|
||||
box_text_clip(iced::Vector::ZERO, card, scale, Size::new(1000., 1000.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
for size in [Size::new(20., 70.), Size::new(204., 16.), Size::ZERO] {
|
||||
assert_eq!(
|
||||
box_text_clip(
|
||||
iced::Vector::ZERO,
|
||||
Rectangle::new(Point::ORIGIN, size),
|
||||
1.,
|
||||
card.size()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_wide_latin_header_exceeds_old_character_budget() {
|
||||
use iced::advanced::text::{self, Paragraph as _};
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
f.project.elements.get_mut(&f.receiver).unwrap().name = "W".repeat(18);
|
||||
f.project.elements.get_mut(&f.work).unwrap().name = "W".repeat(24);
|
||||
let scene = f.scene();
|
||||
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
|
||||
for (content, size, divisor) in [(&header.title, 13., 7.), (&header.detail, 11., 6.)] {
|
||||
let label = iced::widget::canvas::Text {
|
||||
content: crate::views::short(content, ((header.rect.width - 25.) / divisor) as usize),
|
||||
size: size.into(),
|
||||
..Default::default()
|
||||
};
|
||||
// Use the same real paragraph/font/shaping defaults as canvas Text.
|
||||
// The native defect survives short(): glyph width, not scalar count,
|
||||
// is what exceeds the card. This is a font witness, not a pixel gate.
|
||||
let paragraph = <iced::Renderer as text::Renderer>::Paragraph::with_text(text::Text {
|
||||
content: label.content.as_str(),
|
||||
bounds: Size::INFINITE,
|
||||
size: label.size,
|
||||
line_height: label.line_height,
|
||||
font: label.font,
|
||||
align_x: label.align_x,
|
||||
align_y: label.align_y,
|
||||
shaping: label.shaping,
|
||||
wrapping: text::Wrapping::default(),
|
||||
});
|
||||
let old_available = header.rect.width - 12.;
|
||||
assert!(
|
||||
paragraph.min_width() > old_available,
|
||||
"wide glyph fixture must overflow the old unbounded card: {} <= {old_available}",
|
||||
paragraph.min_width()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
|
||||
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
|
||||
for (scale, expected) in [
|
||||
(
|
||||
0.05,
|
||||
Rectangle::new(Point::new(5.4, 10.2), Size::new(6.2, 1.)),
|
||||
),
|
||||
(
|
||||
0.5,
|
||||
Rectangle::new(Point::new(54., 102.), Size::new(62., 10.)),
|
||||
),
|
||||
(
|
||||
1.,
|
||||
Rectangle::new(Point::new(108., 204.), Size::new(124., 20.)),
|
||||
),
|
||||
(
|
||||
2.,
|
||||
Rectangle::new(Point::new(216., 408.), Size::new(248., 40.)),
|
||||
),
|
||||
] {
|
||||
let clip =
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)).unwrap();
|
||||
for (actual, expected) in [
|
||||
(clip.x, expected.x),
|
||||
(clip.y, expected.y),
|
||||
(clip.width, expected.width),
|
||||
(clip.height, expected.height),
|
||||
] {
|
||||
assert!((actual - expected).abs() < 0.0001);
|
||||
}
|
||||
assert!(capsule.contains(Point::new(clip.x / scale, clip.y / scale)));
|
||||
assert!(capsule.contains(Point::new(
|
||||
(clip.x + clip.width) / scale,
|
||||
(clip.y + clip.height) / scale,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birth_label_clip_preserves_parent_viewport_bounds() {
|
||||
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
|
||||
assert_eq!(
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, 2., Size::new(300., 420.)),
|
||||
Some(Rectangle::new(Point::new(216., 408.), Size::new(84., 12.)))
|
||||
);
|
||||
let crossing_origin = Rectangle::new(Point::new(-20., -10.), Size::new(140., 28.));
|
||||
assert_eq!(
|
||||
birth_label_clip(
|
||||
iced::Vector::ZERO,
|
||||
crossing_origin,
|
||||
2.,
|
||||
Size::new(300., 420.)
|
||||
),
|
||||
Some(Rectangle::new(Point::ORIGIN, Size::new(224., 28.)))
|
||||
);
|
||||
for viewport in [Size::new(200., 420.), Size::new(300., 400.), Size::ZERO] {
|
||||
assert_eq!(
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, 2., viewport),
|
||||
None
|
||||
);
|
||||
}
|
||||
for scale in [0., -1., f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(
|
||||
birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_empty_and_create_only_use_visible_root_extents_not_insertion_bounds() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let empty = f.scene();
|
||||
assert_eq!(empty.sequence_bounds[&f.owner], (106., 106.));
|
||||
assert_eq!(
|
||||
intervals(&empty, f.receiver, f.owner),
|
||||
vec![(85., empty.size.height - 20., Life::Alive)]
|
||||
);
|
||||
assert!(empty.lines.is_empty(), "lane guides are not message lines");
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
let scene = f.scene();
|
||||
let birth = scene.rows[&create].1;
|
||||
assert_eq!(birth, 148.);
|
||||
assert_eq!(scene.sequence_bounds[&f.owner], (106., 198.));
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, f.owner),
|
||||
vec![
|
||||
(85., birth, Life::Absent),
|
||||
(birth, scene.size.height - 20., Life::Alive),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&scene, f.sender, f.owner),
|
||||
vec![(85., scene.size.height - 20., Life::Alive)]
|
||||
);
|
||||
assert_eq!(scene.slots[0].y, 106.);
|
||||
assert_eq!(
|
||||
scene.lane_at(Point::new(scene.lanes[&f.receiver], 90.)),
|
||||
Some(f.receiver)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_birth_death_markers_align_endpoints_and_win_selection_without_mutating_source() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
f.occurrence(
|
||||
f.owner,
|
||||
"Work",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let delete = f.message(f.owner, MessageKind::Delete);
|
||||
let mut scene = f.scene();
|
||||
let birth = scene.rows[&create].1;
|
||||
let death = scene.rows[&delete].1;
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, f.owner),
|
||||
vec![
|
||||
(85., birth, Life::Absent),
|
||||
(birth, death, Life::Alive),
|
||||
(death, scene.size.height - 20., Life::Absent),
|
||||
]
|
||||
);
|
||||
let capsule = scene
|
||||
.lifecycle
|
||||
.markers
|
||||
.iter()
|
||||
.find(|m| m.event.occurrence == create)
|
||||
.unwrap()
|
||||
.capsule;
|
||||
let line = scene.lines.iter().find(|l| l.id == create).unwrap();
|
||||
let expected_x = if scene.lanes[&f.sender] < scene.lanes[&f.receiver] {
|
||||
capsule.x
|
||||
} else {
|
||||
capsule.x + capsule.width
|
||||
};
|
||||
assert_eq!(line.points.last(), Some(&Point::new(expected_x, birth)));
|
||||
assert!(
|
||||
line.label_at.y + 22. < capsule.y,
|
||||
"message caption clears birth capsule"
|
||||
);
|
||||
let line = scene.lines.iter().find(|l| l.id == delete).unwrap();
|
||||
assert_eq!(
|
||||
line.points.last(),
|
||||
Some(&Point::new(scene.lanes[&f.receiver], death))
|
||||
);
|
||||
for scale in [0.5, 1., 2.] {
|
||||
scene.scale = scale;
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(scene.lanes[&f.receiver], birth)),
|
||||
Some(create)
|
||||
);
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(scene.lanes[&f.receiver], death)),
|
||||
Some(delete)
|
||||
);
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(
|
||||
scene.lanes[&f.receiver],
|
||||
scene.size.height - 25.
|
||||
)),
|
||||
Some(f.receiver)
|
||||
);
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(scene.lanes[&f.receiver], 40.)),
|
||||
Some(f.receiver)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_branch_delete_clips_only_its_activation_and_keeps_unanswered_call_visible() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let call = f.message(f.owner, MessageKind::Call);
|
||||
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
|
||||
let left = f.branch(fragment);
|
||||
let delete = f.message(left, MessageKind::Delete);
|
||||
let right = f.branch(fragment);
|
||||
let scene = f.scene();
|
||||
let death = scene.rows[&delete].1;
|
||||
let (left_start, left_end) = scene.sequence_bounds[&left];
|
||||
let (right_start, _) = scene.sequence_bounds[&right];
|
||||
let bars: Vec<_> = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|b| b.id == call && b.rect.width == 8.)
|
||||
.map(|b| b.rect)
|
||||
.collect();
|
||||
assert!(
|
||||
bars.iter()
|
||||
.any(|r| r.y == left_start && r.y + r.height == death)
|
||||
);
|
||||
assert!(
|
||||
!bars
|
||||
.iter()
|
||||
.any(|r| r.y < left_end && r.y + r.height > death && r.y >= left_start)
|
||||
);
|
||||
assert!(
|
||||
bars.iter().any(|r| r.y == right_start && r.height == 26.),
|
||||
"empty exclusive sibling retains its own open activation"
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, right),
|
||||
vec![(right_start, right_start + 26., Life::Alive)]
|
||||
);
|
||||
let frame_end = scene.boxes.iter().find(|b| b.id == fragment).unwrap().rect;
|
||||
assert!(
|
||||
!bars.iter().any(|r| r.y >= frame_end.y + frame_end.height),
|
||||
"uncertain post-fragment lifetime cannot carry a definite activation bar"
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.open_calls
|
||||
.iter()
|
||||
.any(|p| p.y < death && p.y > left_start)
|
||||
);
|
||||
assert!(scene.open_calls.iter().any(|p| p.y > right_start));
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, left),
|
||||
vec![
|
||||
(left_start, death, Life::Alive),
|
||||
(death, left_end, Life::Absent)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, f.owner).last().unwrap().2,
|
||||
Life::Unresolved
|
||||
);
|
||||
assert!(!f.project.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_unassigned_lanes_and_explicit_incomplete_receiver_keep_real_marker_identity() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.receiver)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline { represented: None };
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = f.project.elements.get_mut(&create).unwrap().shared_mut()
|
||||
{
|
||||
*exchange = None;
|
||||
}
|
||||
let scene = f.scene();
|
||||
assert!(
|
||||
!scene.lanes.contains_key(&f.receiver),
|
||||
"authoring eligibility remains separate"
|
||||
);
|
||||
let x = scene.lifecycle.lanes[&f.receiver];
|
||||
assert_eq!(scene.lifecycle.markers[0].lifeline, f.receiver);
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(x, scene.rows[&create].1)),
|
||||
Some(create)
|
||||
);
|
||||
assert_eq!(scene.hit(Point::new(x, 40.)), Some(f.receiver));
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(x, scene.size.height - 25.)),
|
||||
Some(f.receiver)
|
||||
);
|
||||
assert_eq!(scene.lane_at(Point::new(x, 90.)), None);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
receiver, exchange, ..
|
||||
},
|
||||
..
|
||||
}) = f.project.elements.get_mut(&create).unwrap().shared_mut()
|
||||
{
|
||||
*receiver = None;
|
||||
*exchange = Some(f.exchange);
|
||||
}
|
||||
let missing = f.scene();
|
||||
assert!(
|
||||
missing.lifecycle.markers.is_empty(),
|
||||
"do not infer receiver from global exchange"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_minimum_zoom_marker_halos_do_not_steal_adjacent_capsule_or_header_hits() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.receiver)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline { represented: None };
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
let delete = f.message(f.owner, MessageKind::Delete);
|
||||
for event in [create, delete] {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = f.project.elements.get_mut(&event).unwrap().shared_mut()
|
||||
{
|
||||
*exchange = None;
|
||||
}
|
||||
}
|
||||
let mut scene = f.scene();
|
||||
let x = scene.lifecycle.lanes[&f.receiver];
|
||||
assert_eq!(scene.rows[&delete].1 - scene.rows[&create].1, 92.);
|
||||
for scale in [0.05, 0.1, 0.5, 1., 2.] {
|
||||
scene.scale = scale;
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(x, scene.rows[&create].1)),
|
||||
Some(create)
|
||||
);
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(x, scene.rows[&delete].1)),
|
||||
Some(delete)
|
||||
);
|
||||
assert_eq!(scene.hit(Point::new(x, 40.)), Some(f.receiver));
|
||||
if scale <= 0.1 {
|
||||
for offset in [-25., -15., 15., 25.] {
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(x, scene.rows[&delete].1 + offset)),
|
||||
Some(delete),
|
||||
"a nearby death halo must beat an earlier message's wider tolerance"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_minimum_zoom_near_ordinary_message_wins_over_lifecycle_halos() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
f.message(f.owner, MessageKind::Create);
|
||||
let ordinary = f.message(f.owner, MessageKind::Asynchronous);
|
||||
f.message(f.owner, MessageKind::Delete);
|
||||
let mut scene = f.scene();
|
||||
let x = (scene.lanes[&f.sender] + scene.lanes[&f.receiver]) / 2.;
|
||||
for scale in [0.05, 0.1] {
|
||||
scene.scale = scale;
|
||||
assert_eq!(
|
||||
scene.hit(Point::new(x, scene.rows[&ordinary].1 + 2.)),
|
||||
Some(ordinary)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_self_lifecycle_elbows_end_on_recorded_marker_rows() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for kind in [MessageKind::Create, MessageKind::Delete] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let event = f.message(f.owner, kind);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange, sender, ..
|
||||
},
|
||||
..
|
||||
}) = f.project.elements.get_mut(&event).unwrap().shared_mut()
|
||||
{
|
||||
*exchange = None;
|
||||
*sender = Some(f.receiver);
|
||||
}
|
||||
let scene = f.scene();
|
||||
let marker = &scene.lifecycle.markers[0];
|
||||
let line = scene.lines.iter().find(|l| l.id == event).unwrap();
|
||||
assert_eq!(line.points.len(), 4);
|
||||
assert_eq!(line.points[0].y, marker.center.y - 24.);
|
||||
assert_eq!(line.points[3].y, marker.center.y);
|
||||
assert_eq!(
|
||||
line.points[3].x,
|
||||
if marker.event.kind == MarkerKind::Birth {
|
||||
marker.capsule.x + marker.capsule.width
|
||||
} else {
|
||||
marker.center.x
|
||||
}
|
||||
);
|
||||
assert!(line.points[1].x > marker.capsule.x + marker.capsule.width);
|
||||
assert_eq!(scene.hit(marker.center), Some(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_self_delete_caption_clears_the_preceding_action_box() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let action = f.occurrence(
|
||||
f.owner,
|
||||
"Finish work",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let delete = f.message(f.owner, MessageKind::Delete);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange, sender, ..
|
||||
},
|
||||
..
|
||||
}) = f.project.elements.get_mut(&delete).unwrap().shared_mut()
|
||||
{
|
||||
*exchange = None;
|
||||
*sender = Some(f.receiver);
|
||||
}
|
||||
let scene = f.scene();
|
||||
let action = scene.boxes.iter().find(|b| b.id == action).unwrap();
|
||||
let line = scene.lines.iter().find(|l| l.id == delete).unwrap();
|
||||
assert!(line.label_at.y > action.rect.y + action.rect.height);
|
||||
assert!(line.label_at.y + 22. < line.points[0].y);
|
||||
assert_eq!(line.points[3].y, scene.rows[&delete].1);
|
||||
assert_eq!(
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.find(|s| s.slot.before == Some(delete))
|
||||
.unwrap()
|
||||
.y,
|
||||
scene.rows[&delete].1 - 42.
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_conditional_repeated_and_parallel_scopes_do_not_claim_definite_continuation() {
|
||||
for operator in [
|
||||
FragmentOperator::Alternative,
|
||||
FragmentOperator::Optional,
|
||||
FragmentOperator::Loop,
|
||||
FragmentOperator::Parallel,
|
||||
] {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let fragment = f.fragment(f.owner, operator);
|
||||
let first = f.branch(fragment);
|
||||
let create = f.message(first, MessageKind::Create);
|
||||
let scene = f.scene();
|
||||
assert_eq!(scene.lifecycle.markers[0].event.occurrence, create);
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, f.owner).first().unwrap().2,
|
||||
Life::Unresolved
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&scene, f.receiver, f.owner).last().unwrap().2,
|
||||
Life::Unresolved
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.lifecycle
|
||||
.lifetimes
|
||||
.iter()
|
||||
.find(|l| l.lifeline == f.sender)
|
||||
.unwrap()
|
||||
.intervals
|
||||
.iter()
|
||||
.all(|i| i.life == Life::Alive)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_lifecycle_edit_undo_redo_restores_exact_source_and_changed_geometry() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
let mut document = f.document();
|
||||
let before = document.snapshot();
|
||||
let geometry = Scene::sequence(document.project(), f.owner, create).lifecycle;
|
||||
let mut edited = document.project().elements[&create].clone();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||
..
|
||||
}) = edited.shared_mut()
|
||||
{
|
||||
*message_kind = MessageKind::Asynchronous;
|
||||
}
|
||||
document
|
||||
.execute(Command::EditElement(create, edited))
|
||||
.unwrap();
|
||||
let after = document.snapshot();
|
||||
let changed = Scene::sequence(document.project(), f.owner, create).lifecycle;
|
||||
assert_ne!(geometry, changed);
|
||||
assert!(changed.markers.is_empty());
|
||||
assert_ne!(before.bundle, after.bundle);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert_eq!(
|
||||
Scene::sequence(document.project(), f.owner, create).lifecycle,
|
||||
geometry
|
||||
);
|
||||
assert!(!document.can_undo() && document.can_redo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &after.decoded.project);
|
||||
assert_eq!(document.bundle(), &after.bundle);
|
||||
assert_eq!(
|
||||
Scene::sequence(document.project(), f.owner, create).lifecycle,
|
||||
changed
|
||||
);
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panned_header_and_create_clips_use_screen_translation_and_actual_viewport() {
|
||||
use crate::behavior::box_text_clip;
|
||||
use iced::Vector;
|
||||
let header = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
|
||||
let capsule = Rectangle::new(header.position(), Size::new(140., 28.));
|
||||
let rect = |x, y, w, h| Rectangle::new(Point::new(x, y), Size::new(w, h));
|
||||
for (pan, scale, viewport, header_expected, birth_expected) in [
|
||||
(
|
||||
Vector::new(37., -41.),
|
||||
0.5,
|
||||
Size::new(300., 400.),
|
||||
rect(92., 63., 92., 27.),
|
||||
rect(91., 61., 62., 10.),
|
||||
),
|
||||
(
|
||||
Vector::new(37., -41.),
|
||||
1.,
|
||||
Size::new(300., 400.),
|
||||
rect(147., 167., 153., 54.),
|
||||
rect(145., 163., 124., 20.),
|
||||
),
|
||||
(
|
||||
Vector::new(37., -41.),
|
||||
2.,
|
||||
Size::new(300., 400.),
|
||||
rect(257., 375., 43., 25.),
|
||||
rect(253., 367., 47., 33.),
|
||||
),
|
||||
(
|
||||
Vector::new(-200., -400.),
|
||||
2.,
|
||||
Size::new(300., 100.),
|
||||
rect(20., 16., 280., 84.),
|
||||
rect(16., 8., 248., 40.),
|
||||
),
|
||||
(
|
||||
Vector::new(-237., -411.),
|
||||
2.,
|
||||
Size::new(300., 100.),
|
||||
rect(0., 5., 300., 95.),
|
||||
rect(0., 0., 227., 37.),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
box_text_clip(pan, header, scale, viewport),
|
||||
Some(header_expected)
|
||||
);
|
||||
assert_eq!(
|
||||
birth_label_clip(pan, capsule, scale, viewport),
|
||||
Some(birth_expected)
|
||||
);
|
||||
}
|
||||
for pan in [
|
||||
Vector::new(1000., 1000.),
|
||||
Vector::new(-1000., -1000.),
|
||||
Vector::new(f32::NAN, 0.),
|
||||
Vector::new(0., f32::INFINITY),
|
||||
] {
|
||||
assert_eq!(box_text_clip(pan, header, 2., Size::new(300., 100.)), None);
|
||||
assert_eq!(
|
||||
birth_label_clip(pan, capsule, 2., Size::new(300., 100.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity() {
|
||||
use crate::{
|
||||
app::Message,
|
||||
behavior::{
|
||||
SequenceInput,
|
||||
scenario::{CanvasEvent, CanvasTool},
|
||||
},
|
||||
};
|
||||
use iced::{Vector, mouse, widget::canvas};
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
f.project.elements.get_mut(&f.receiver).unwrap().name = "界".repeat(24);
|
||||
let create = f.message(f.owner, MessageKind::Create);
|
||||
let delete = f.message(f.owner, MessageKind::Delete);
|
||||
let document = f.document();
|
||||
let before = document.snapshot();
|
||||
for scale in [0.5, 1., 2.] {
|
||||
for pan in [Vector::new(35., 40.), Vector::new(-35., -5.)] {
|
||||
let mut scene = f.scene();
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = pan;
|
||||
let context = crate::behavior::scenario::direct_tests::context(f.owner);
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let header = scene.boxes.iter().find(|b| b.id == f.sender).unwrap();
|
||||
let targets = [
|
||||
(f.sender, header.rect.center()),
|
||||
(
|
||||
create,
|
||||
scene
|
||||
.lifecycle
|
||||
.markers
|
||||
.iter()
|
||||
.find(|m| m.event.occurrence == create)
|
||||
.unwrap()
|
||||
.center,
|
||||
),
|
||||
(
|
||||
delete,
|
||||
scene
|
||||
.lifecycle
|
||||
.markers
|
||||
.iter()
|
||||
.find(|m| m.event.occurrence == delete)
|
||||
.unwrap()
|
||||
.center,
|
||||
),
|
||||
];
|
||||
// Absolute widget origin models the outer layout/scroll translation;
|
||||
// camera pan remains local screen units and must be subtracted once.
|
||||
let bounds = Rectangle::new(Point::new(73., 119.), Size::new(2000., 1200.));
|
||||
for (expected, world) in targets {
|
||||
let screen =
|
||||
bounds.position() + pan + Vector::new(world.x * scale, world.y * scale);
|
||||
assert!(bounds.contains(screen));
|
||||
let mut state = SequenceInput::default();
|
||||
let mut published = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: screen },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
if let Some(message) = canvas::Program::update(
|
||||
&scene,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
{
|
||||
published.push(message);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!(published.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Select(actual_context, actual))] if *actual_context == context && *actual == expected),
|
||||
"Pan/zoom must retain the typed hit identity"
|
||||
);
|
||||
}
|
||||
assert_eq!(scene.lifecycle, f.scene().lifecycle);
|
||||
}
|
||||
}
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert!(!document.can_undo() && !document.dirty());
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
use super::*;
|
||||
use oa_core::*;
|
||||
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owner: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
exchange: Id,
|
||||
rows: BTreeMap<Id, (Id, f32)>,
|
||||
scopes: BTreeMap<Id, (f32, f32)>,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new(layer: Layer) -> Self {
|
||||
let mut p = Project::empty();
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let a = p.add_element(kind, layer, "Request", None).unwrap();
|
||||
let b = p.add_element(kind, layer, "Provide", None).unwrap();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, layer, "Lifecycle", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Service",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
let line = |p: &mut Project, represented| {
|
||||
let id = p
|
||||
.add_element(Kind::Lifeline, layer, "Participant", Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
id
|
||||
};
|
||||
let a = line(&mut p, a);
|
||||
let b = line(&mut p, b);
|
||||
Self {
|
||||
p,
|
||||
owner,
|
||||
a,
|
||||
b,
|
||||
exchange,
|
||||
rows: BTreeMap::new(),
|
||||
scopes: [(owner, (0., 600.))].into(),
|
||||
}
|
||||
}
|
||||
fn occurrence(&mut self, owner: Id, y: f32, body: OccurrenceBody) -> Id {
|
||||
let mut e = self
|
||||
.p
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
self.p.elements[&owner].layer(),
|
||||
"Occurrence",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let id = e.id;
|
||||
*e.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
self.p.elements.insert(id, e);
|
||||
if let Some(steps) = self.p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
steps.push(id);
|
||||
} else if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = self.p.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(id);
|
||||
}
|
||||
self.rows.insert(id, (owner, y));
|
||||
id
|
||||
}
|
||||
fn message(&mut self, owner: Id, y: f32, kind: MessageKind) -> Id {
|
||||
self.occurrence(
|
||||
owner,
|
||||
y,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(self.exchange),
|
||||
sender: Some(self.a),
|
||||
receiver: Some(self.b),
|
||||
message_kind: kind,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn fragment(&mut self, owner: Id, y: f32, operator: FragmentOperator) -> Id {
|
||||
self.occurrence(
|
||||
owner,
|
||||
y,
|
||||
OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn branch(&mut self, fragment: Id, start: f32, end: f32) -> Id {
|
||||
let id = self.occurrence(
|
||||
fragment,
|
||||
start,
|
||||
OccurrenceBody::Branch {
|
||||
guard: "condition".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
self.scopes.insert(id, (start, end));
|
||||
id
|
||||
}
|
||||
fn project(&self) -> Vec<LaneLifetime> {
|
||||
let errors: Vec<_> = validation::validate(&self.p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"fixture must be ordinarily admitted: {errors:?}"
|
||||
);
|
||||
let before = self.p.clone();
|
||||
let source = source_project::format_bundle(&self.p);
|
||||
let geometry = Geometry {
|
||||
rows: &self.rows,
|
||||
scopes: &self.scopes,
|
||||
};
|
||||
let result = project_lifetimes(&self.p, self.owner, &geometry);
|
||||
assert_eq!(result, project_lifetimes(&self.p, self.owner, &geometry));
|
||||
assert_eq!(self.p, before);
|
||||
assert_eq!(source_project::format_bundle(&self.p), source);
|
||||
assert!(
|
||||
result
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].lifeline < pair[1].lifeline)
|
||||
);
|
||||
result
|
||||
}
|
||||
fn lane(&self) -> LaneLifetime {
|
||||
self.project()
|
||||
.into_iter()
|
||||
.find(|lane| lane.lifeline == self.b)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
fn intervals(lane: &LaneLifetime, scope: Id) -> Vec<(f32, f32, Life)> {
|
||||
lane.intervals
|
||||
.iter()
|
||||
.filter(|i| i.scope == scope)
|
||||
.map(|i| (i.span.start, i.span.end, i.life))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_lifetime_has_exact_birth_death_boundaries_without_source_mutation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = Fixture::new(layer);
|
||||
let create = f.message(f.owner, 100., MessageKind::Create);
|
||||
f.message(f.owner, 200., MessageKind::Asynchronous);
|
||||
let delete = f.message(f.owner, 300., MessageKind::Delete);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![
|
||||
(0., 100., Life::Absent),
|
||||
(100., 300., Life::Alive),
|
||||
(300., 600., Life::Absent)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
lane.markers,
|
||||
vec![
|
||||
Marker {
|
||||
occurrence: create,
|
||||
scope: f.owner,
|
||||
y: 100.,
|
||||
kind: MarkerKind::Birth
|
||||
},
|
||||
Marker {
|
||||
occurrence: delete,
|
||||
scope: f.owner,
|
||||
y: 300.,
|
||||
kind: MarkerKind::Death
|
||||
},
|
||||
]
|
||||
);
|
||||
let sender = f
|
||||
.project()
|
||||
.into_iter()
|
||||
.find(|lane| lane.lifeline == f.a)
|
||||
.unwrap();
|
||||
assert_eq!(intervals(&sender, f.owner), vec![(0., 600., Life::Alive)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternative_death_does_not_kill_its_displayed_sibling() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let fragment = f.fragment(f.owner, 100., FragmentOperator::Alternative);
|
||||
let left = f.branch(fragment, 120., 250.);
|
||||
let right = f.branch(fragment, 300., 450.);
|
||||
f.message(left, 200., MessageKind::Delete);
|
||||
f.message(right, 350., MessageKind::Asynchronous);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, left),
|
||||
vec![(120., 200., Life::Alive), (200., 250., Life::Absent)]
|
||||
);
|
||||
assert_eq!(intervals(&lane, right), vec![(300., 450., Life::Alive)]);
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![(0., 100., Life::Alive), (450., 600., Life::Unresolved)]
|
||||
);
|
||||
assert_eq!(
|
||||
lane.uncertainties,
|
||||
vec![Uncertainty {
|
||||
scope: fragment,
|
||||
reason: UncertaintyReason::Conditional
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_birth_does_not_infer_absence_in_siblings_or_ancestors() {
|
||||
for operator in [FragmentOperator::Alternative, FragmentOperator::Optional] {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let fragment = f.fragment(f.owner, 100., operator);
|
||||
let left = f.branch(fragment, 120., 250.);
|
||||
let right = f.branch(fragment, 300., 450.);
|
||||
f.message(left, 200., MessageKind::Create);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, left),
|
||||
vec![(120., 200., Life::Absent), (200., 250., Life::Alive)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, right),
|
||||
vec![(300., 450., Life::Unresolved)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![(0., 100., Life::Unresolved), (450., 600., Life::Unresolved)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_lifetime_is_local_and_warning_bearing_loops_remain_drawable() {
|
||||
for balanced in [true, false] {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let fragment = f.fragment(f.owner, 100., FragmentOperator::Loop);
|
||||
let iteration = f.branch(fragment, 120., 450.);
|
||||
let create = f.message(iteration, 160., MessageKind::Create);
|
||||
f.message(iteration, 250., MessageKind::Asynchronous);
|
||||
if balanced {
|
||||
f.message(iteration, 350., MessageKind::Delete);
|
||||
}
|
||||
let lane = f.lane();
|
||||
assert_eq!(lane.markers.len(), if balanced { 2 } else { 1 });
|
||||
assert_eq!(lane.markers[0].occurrence, create);
|
||||
assert!(lane.uncertainties.contains(&Uncertainty {
|
||||
scope: fragment,
|
||||
reason: UncertaintyReason::Repeated
|
||||
}));
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner).last().unwrap().2,
|
||||
Life::Unresolved
|
||||
);
|
||||
assert_eq!(
|
||||
validation::validate(&f.p)
|
||||
.iter()
|
||||
.any(|i| i.code == "lifeline_repetition"),
|
||||
!balanced
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_cleanup_inside_loop_does_not_claim_balanced_or_definite_exit() {
|
||||
let mut f = Fixture::new(Layer::Sa);
|
||||
let repeated = f.fragment(f.owner, 100., FragmentOperator::Loop);
|
||||
let iteration = f.branch(repeated, 120., 500.);
|
||||
f.message(iteration, 160., MessageKind::Create);
|
||||
let optional = f.fragment(iteration, 280., FragmentOperator::Optional);
|
||||
let cleanup = f.branch(optional, 320., 400.);
|
||||
f.message(cleanup, 350., MessageKind::Delete);
|
||||
let lane = f.lane();
|
||||
assert_eq!(
|
||||
intervals(&lane, cleanup),
|
||||
vec![(320., 350., Life::Alive), (350., 400., Life::Absent)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, iteration),
|
||||
vec![
|
||||
(120., 160., Life::Absent),
|
||||
(160., 280., Life::Alive),
|
||||
(400., 500., Life::Unresolved)
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&f.p)
|
||||
.iter()
|
||||
.any(|i| i.code == "lifeline_repetition")
|
||||
);
|
||||
assert!(lane.uncertainties.contains(&Uncertainty {
|
||||
scope: optional,
|
||||
reason: UncertaintyReason::Conditional
|
||||
}));
|
||||
assert!(lane.uncertainties.contains(&Uncertainty {
|
||||
scope: repeated,
|
||||
reason: UncertaintyReason::Repeated
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_free_fragments_do_not_poison_other_lanes() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let fragment = f.fragment(f.owner, 100., FragmentOperator::Parallel);
|
||||
let left = f.branch(fragment, 120., 250.);
|
||||
let right = f.branch(fragment, 300., 450.);
|
||||
f.message(left, 200., MessageKind::Delete);
|
||||
let result = f.project();
|
||||
let sender = result.iter().find(|lane| lane.lifeline == f.a).unwrap();
|
||||
assert!(sender.intervals.iter().all(|i| i.life == Life::Alive));
|
||||
assert!(sender.uncertainties.is_empty());
|
||||
let target = result.iter().find(|lane| lane.lifeline == f.b).unwrap();
|
||||
assert!(target.uncertainties.contains(&Uncertainty {
|
||||
scope: fragment,
|
||||
reason: UncertaintyReason::Parallel
|
||||
}));
|
||||
assert_eq!(intervals(target, right), vec![(300., 450., Life::Alive)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_receiver_is_authoritative_even_with_missing_exchange() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let create = f.message(f.owner, 100., MessageKind::Create);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
|
||||
{
|
||||
*exchange = None;
|
||||
}
|
||||
assert_eq!(f.lane().markers[0].occurrence, create);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
receiver, exchange, ..
|
||||
},
|
||||
..
|
||||
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
|
||||
{
|
||||
*receiver = None;
|
||||
*exchange = Some(f.exchange);
|
||||
}
|
||||
assert!(
|
||||
f.lane().markers.is_empty(),
|
||||
"exchange endpoints never invent a receiver"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_geometry_is_reported_without_nonfinite_or_guessed_markers() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let create = f.message(f.owner, 100., MessageKind::Create);
|
||||
f.rows.insert(create, (f.owner, f32::NAN));
|
||||
let lane = f.lane();
|
||||
assert!(lane.markers.is_empty());
|
||||
assert_eq!(
|
||||
lane.uncertainties,
|
||||
vec![Uncertainty {
|
||||
scope: create,
|
||||
reason: UncertaintyReason::MissingGeometry
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(&lane, f.owner),
|
||||
vec![(0., 600., Life::Unresolved)]
|
||||
);
|
||||
f.scopes.remove(&f.owner);
|
||||
let lane = f.lane();
|
||||
assert!(lane.intervals.is_empty());
|
||||
assert_eq!(lane.uncertainties[0].scope, f.owner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_use_is_opaque_and_never_imports_callee_lifecycle_events() {
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
let callee =
|
||||
f.p.add_element(Kind::Scenario, Layer::Oa, "Callee", None)
|
||||
.unwrap();
|
||||
let remote_a =
|
||||
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote A", Some(callee))
|
||||
.unwrap();
|
||||
let remote_b =
|
||||
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote B", Some(callee))
|
||||
.unwrap();
|
||||
for (remote, local) in [(remote_a, f.a), (remote_b, f.b)] {
|
||||
let body = f.p.elements[&local].shared().unwrap().clone();
|
||||
*f.p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() = body;
|
||||
}
|
||||
f.occurrence(
|
||||
callee,
|
||||
200.,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: Some(remote_a),
|
||||
receiver: Some(remote_b),
|
||||
message_kind: MessageKind::Create,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.occurrence(
|
||||
f.owner,
|
||||
100.,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(callee),
|
||||
bindings: vec![
|
||||
LifelineBinding {
|
||||
referenced: remote_a,
|
||||
local: f.a,
|
||||
},
|
||||
LifelineBinding {
|
||||
referenced: remote_b,
|
||||
local: f.b,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
let lane = f.lane();
|
||||
assert_eq!(intervals(&lane, f.owner), vec![(0., 600., Life::Alive)]);
|
||||
assert!(lane.markers.is_empty());
|
||||
assert!(lane.uncertainties.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_retains_exact_authored_source_and_does_not_enter_history() {
|
||||
use crate::document::Document;
|
||||
let mut f = Fixture::new(Layer::Oa);
|
||||
f.message(f.owner, 100., MessageKind::Create);
|
||||
f.message(f.owner, 300., MessageKind::Delete);
|
||||
let mut source = source_project::format_bundle(&f.p);
|
||||
let text = source.files.get_mut(&source.entry).unwrap();
|
||||
*text = format!("// Retained author note — 雨\r\n{text}\n");
|
||||
let document =
|
||||
Document::from_bundle(source.clone(), source_project::ProjectLimits::default()).unwrap();
|
||||
let accepted = document.project().clone();
|
||||
let geometry = Geometry {
|
||||
rows: &f.rows,
|
||||
scopes: &f.scopes,
|
||||
};
|
||||
let result = project_lifetimes(document.project(), f.owner, &geometry);
|
||||
assert_eq!(result, f.project());
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert_eq!(document.project(), &accepted);
|
||||
assert!(!document.can_undo());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,6 @@ impl Tool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool, Option<Id>),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -59,6 +58,7 @@ pub enum Event {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Shelf {
|
||||
Tools,
|
||||
Selection,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -79,11 +79,10 @@ pub struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
details_offset: iced::Vector,
|
||||
return_to: Vec<direct::ReturnFrame>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
pub(crate) focused: bool,
|
||||
@@ -91,22 +90,15 @@ pub struct State {
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
}
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
#[serde(with = "recovery_insertion")]
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
@@ -125,40 +117,6 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.tool,
|
||||
self.focused,
|
||||
self.shelf,
|
||||
self.viewport,
|
||||
self.pan,
|
||||
self.zoom,
|
||||
self.fit,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
@@ -194,58 +152,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Work {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Work { owner: d.owner },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -278,9 +184,6 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
@@ -350,8 +253,8 @@ impl State {
|
||||
}
|
||||
|
||||
fn fit_area(&self, fallback_width: f32) -> Size {
|
||||
// The sensor measures the viewport, independent of graph content.
|
||||
// Leave a small content gutter; before first layout use a compact
|
||||
// The sensor measures the inner scroller, independent of graph content.
|
||||
// Leave a small gutter for scrollbars; before first layout use a compact
|
||||
// provisional viewport and recompute when the sensor reports actual size.
|
||||
let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.));
|
||||
Size::new(
|
||||
@@ -360,7 +263,10 @@ impl State {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn canvas_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
|
||||
fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool {
|
||||
let available = self.fit_area(fallback_width);
|
||||
self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height)
|
||||
@@ -602,22 +508,6 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Work { owner: self.owner },
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
let owner = p
|
||||
.elements
|
||||
.get(&self.owner)
|
||||
@@ -725,8 +615,6 @@ impl Draft {
|
||||
});
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default_name
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -978,8 +866,7 @@ mod contextual_routing;
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
camera_pan: iced::Vector,
|
||||
pending_insertion: Option<Insertion>,
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
ordered_paths: Vec<OrderedPath>,
|
||||
@@ -1311,8 +1198,7 @@ impl<'a> Graph<'a> {
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
ordered_paths,
|
||||
@@ -1412,7 +1298,6 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
f.translate(self.camera_pan);
|
||||
f.scale(self.scale);
|
||||
for pass in PAINT_PASSES {
|
||||
match pass {
|
||||
@@ -1628,10 +1513,7 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if cursor.position_in(bounds).is_some_and(|p| {
|
||||
let p = Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
||||
self.nodes.iter().any(|n| n.rect.contains(p))
|
||||
|| self.ordered_paths.iter().any(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
@@ -1705,9 +1587,6 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: None,
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
@@ -2164,6 +2043,7 @@ mod tests {
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.behavior.prepare_pending(app.project()).is_err());
|
||||
for (shelf, expected) in [
|
||||
(Shelf::Tools, Some(Shelf::Tools)),
|
||||
(Shelf::Selection, Some(Shelf::Selection)),
|
||||
(Shelf::Selection, None),
|
||||
] {
|
||||
@@ -2180,12 +2060,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_camera_selection_ignores_independent_details_scroll() {
|
||||
fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() {
|
||||
let (p, owner, _, _, _, _) = fixture();
|
||||
let mut state = State::default();
|
||||
state.camera(crate::behavior::canvas_workspace::Event::Pan(
|
||||
iced::Vector::new(11., 19.),
|
||||
));
|
||||
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.));
|
||||
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.));
|
||||
let context = Context {
|
||||
document: p.id,
|
||||
@@ -2196,17 +2074,16 @@ mod tests {
|
||||
for scale in [0.45, 1.] {
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
graph.scale = scale;
|
||||
graph.camera_pan = state.pan;
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
let node = &graph.nodes[0];
|
||||
let id = node.id;
|
||||
let point = node.rect.center();
|
||||
let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.));
|
||||
let screen = Point::new(
|
||||
bounds.x + point.x * scale + 11.,
|
||||
bounds.y + point.y * scale + 19.,
|
||||
bounds.x + point.x * scale - 11.,
|
||||
bounds.y + point.y * scale - 19.,
|
||||
);
|
||||
assert!(bounds.contains(screen));
|
||||
let message = graph
|
||||
.input(
|
||||
&mut Input::default(),
|
||||
|
||||
@@ -33,7 +33,7 @@ pub enum Event {
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
@@ -608,11 +608,7 @@ mod tests {
|
||||
"Authored endpoint is retained for explicit repair"
|
||||
);
|
||||
assert!(state.prepare_pending(&deleted).is_err());
|
||||
state.remember(
|
||||
owner,
|
||||
reference,
|
||||
super::super::direct::referenced(&p, owner, reference).unwrap(),
|
||||
);
|
||||
state.remember(owner, reference);
|
||||
assert_eq!(state.back(&deleted), Some((owner, reference)));
|
||||
assert!(state.selected_context.is_none());
|
||||
state.draft = None;
|
||||
|
||||
@@ -60,7 +60,6 @@ pub enum CanvasEvent {
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
@@ -78,7 +77,6 @@ impl CanvasEvent {
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Camera(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
@@ -88,19 +86,6 @@ impl CanvasEvent {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) struct ReturnFrame {
|
||||
identity: Option<(Id, Id)>,
|
||||
owner: Id,
|
||||
occurrence: Id,
|
||||
target: Id,
|
||||
address: Option<oa_core::topology_context::ContextAddress>,
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
viewport: Option<Size>,
|
||||
expanded: BTreeSet<Id>,
|
||||
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
|
||||
self.selected_context = Some(address);
|
||||
@@ -153,49 +138,35 @@ impl State {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
self.tool = CanvasTool::Select;
|
||||
self.pan = iced::Vector::ZERO;
|
||||
self.zoom = Some(1.);
|
||||
self.fit = false;
|
||||
self.viewport = None;
|
||||
self.expanded.clear();
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn details_offset(&self) -> iced::Vector {
|
||||
self.details_offset
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
pub(crate) fn scrolled(
|
||||
&mut self,
|
||||
scenario::ScrollArea::Page: scenario::ScrollArea,
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
}
|
||||
let changed = self.details_offset != offset;
|
||||
self.details_offset = offset;
|
||||
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
scenario::ScrollArea::Canvas => 0,
|
||||
scenario::ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||
// Choosing how to edit must retain the user's current view.
|
||||
self.tool = tool;
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool {
|
||||
super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||
self.fit = value.is_none();
|
||||
self.zoom = value.map(|v| v.clamp(0.05, 3.));
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
|| !size.height.is_finite()
|
||||
|| size.width <= 0.
|
||||
|| size.height <= 0.
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let changed = self.viewport != Some(size);
|
||||
self.viewport = Some(size);
|
||||
changed
|
||||
@@ -217,83 +188,26 @@ impl State {
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id, target: Id) {
|
||||
if self.return_to.len() == 32 {
|
||||
self.return_to.remove(0);
|
||||
}
|
||||
self.return_to.push(ReturnFrame {
|
||||
identity: self.identity,
|
||||
owner,
|
||||
occurrence,
|
||||
target,
|
||||
address: self.selected_context.clone(),
|
||||
pan: self.pan,
|
||||
zoom: self.zoom,
|
||||
fit: self.fit,
|
||||
viewport: self.viewport,
|
||||
expanded: self.expanded.clone(),
|
||||
contextual_expanded: self.contextual_expanded.clone(),
|
||||
});
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some(frame) = self.return_to.pop() {
|
||||
if frame.identity.map(|i| i.0) != self.identity.map(|i| i.0) {
|
||||
continue;
|
||||
}
|
||||
let valid = referenced(p, frame.owner, frame.occurrence) == Some(frame.target)
|
||||
|| if let Some(address) = &frame.address {
|
||||
oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(frame.owner, &address.endpoint)
|
||||
.is_ok_and(|r| r.defining_sequence == frame.target)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if valid && is_contribution(p, frame.owner, frame.occurrence) {
|
||||
self.identity = frame.identity;
|
||||
self.selected_context = frame.address.filter(|a| {
|
||||
oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(frame.owner, &a.endpoint)
|
||||
.is_ok()
|
||||
while let Some((owner, occurrence, address)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
self.selected_context = address.filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
});
|
||||
self.pan = frame.pan;
|
||||
self.zoom = frame.zoom;
|
||||
self.fit = frame.fit;
|
||||
self.viewport = frame.viewport;
|
||||
self.expanded = frame.expanded;
|
||||
self.contextual_expanded = frame.contextual_expanded;
|
||||
return Some((frame.owner, frame.occurrence));
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded);
|
||||
let old = self.scale(graph.size, 500.);
|
||||
if let Some(next) = zoom {
|
||||
let size = self.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(size.width / 2., size.height / 2.),
|
||||
base: old,
|
||||
});
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.scale(graph.size, 500.);
|
||||
self.zoom(Some(fitted));
|
||||
self.pan = iced::Vector::ZERO;
|
||||
}
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
@@ -625,7 +539,7 @@ impl Graph<'_> {
|
||||
{
|
||||
*moved |= position.distance(*start) >= 3.;
|
||||
}
|
||||
mouse::Cursor::Available(*position)
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(
|
||||
@@ -644,19 +558,18 @@ impl Graph<'_> {
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position();
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
@@ -670,7 +583,7 @@ impl Graph<'_> {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let start = cursor.position()?;
|
||||
let start = cursor.position()? - self.scroll_offset;
|
||||
match tool {
|
||||
CanvasTool::Add(..) => {
|
||||
state.press = Some(Press::Add {
|
||||
@@ -809,35 +722,15 @@ impl Graph<'_> {
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
if let Some(insertion) = self.pending_insertion {
|
||||
if let Some(edge) = insertion
|
||||
.link
|
||||
.and_then(|id| self.edges.iter().find(|e| e.id == id))
|
||||
{
|
||||
draw_arrow(f, &edge.points, style::TEAL, 3.);
|
||||
} else if let Some(node) = insertion
|
||||
.before
|
||||
.and_then(|id| self.nodes.iter().find(|n| n.id == id))
|
||||
{
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(3.),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !editable {
|
||||
return;
|
||||
}
|
||||
let point = state
|
||||
.pointer
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| {
|
||||
Point::new(
|
||||
(p.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
@@ -952,7 +845,12 @@ impl Graph<'_> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
@@ -964,25 +862,10 @@ impl State {
|
||||
self.qualified_selection(p, owner)
|
||||
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
|
||||
})
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label));
|
||||
let Some(rect) = rect else {
|
||||
return false;
|
||||
};
|
||||
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
|
||||
let x = rect.x * scale + self.pan.x;
|
||||
let y = rect.y * scale + self.pan.y;
|
||||
let delta = iced::Vector::new(
|
||||
if x < 12. || x + rect.width * scale > viewport.width - 12. {
|
||||
12. - x
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
if y < 12. || y + rect.height * scale > viewport.height - 12. {
|
||||
12. - y
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
self.camera(super::super::canvas_workspace::Event::Pan(delta))
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +540,7 @@ fn send(
|
||||
input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
mouse::Cursor::Available(point + graph.scroll_offset),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
@@ -554,20 +554,16 @@ fn position(graph: &Graph<'_>, id: Id) -> Point {
|
||||
.center()
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_production_click_click_drag_insert_and_camera_coordinates() {
|
||||
fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for drag in [false, true] {
|
||||
let mut graph = graph(&f, CanvasTool::Connect);
|
||||
graph.camera_pan = iced::Vector::new(35., 90.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(10., 20.),
|
||||
Size::new(graph.size.width + 70., graph.size.height + 180.),
|
||||
);
|
||||
graph.scroll_offset = iced::Vector::new(35., 90.);
|
||||
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
|
||||
let screen =
|
||||
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) + graph.camera_pan;
|
||||
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
|
||||
let a = screen(f.nodes[0]);
|
||||
let b = screen(f.nodes[2]);
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
@@ -736,7 +732,7 @@ fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
|
||||
state.zoom(None);
|
||||
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
|
||||
state.zoom(Some(0.1));
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
|
||||
let mut all = Vec::new();
|
||||
@@ -1335,9 +1331,7 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
let document = Id::new_v4();
|
||||
state.enter(document, f.owner);
|
||||
state.viewport(Size::new(500., 300.));
|
||||
state.camera(super::super::canvas_workspace::Event::Pan(
|
||||
iced::Vector::new(35., 90.),
|
||||
));
|
||||
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(35., 90.));
|
||||
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.));
|
||||
for camera in [None, Some(0.75), Some(1.5)] {
|
||||
state.zoom(camera);
|
||||
@@ -1359,16 +1353,90 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
"{layer:?} {camera:?} {tool:?}"
|
||||
);
|
||||
assert_eq!(state.fit, camera.is_none());
|
||||
assert_eq!(state.pan, iced::Vector::new(35., 90.));
|
||||
assert_eq!(state.details_offset(), iced::Vector::new(0., 120.));
|
||||
assert_eq!(state.scroll_offset(), iced::Vector::new(35., 210.));
|
||||
state.enter(document, f.owner);
|
||||
assert_eq!(state.scale(size, 500.), before);
|
||||
}
|
||||
}
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.);
|
||||
assert_eq!(state.pan, iced::Vector::ZERO);
|
||||
assert_eq!(state.details_offset(), iced::Vector::ZERO);
|
||||
assert_eq!(state.scroll_offset(), iced::Vector::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() {
|
||||
use iced::advanced::widget::{Tree, tree};
|
||||
use std::rc::Rc;
|
||||
fn path_to(tree: &Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||
if tree.tag == tag {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
tree.children.iter().enumerate().find_map(|(index, child)| {
|
||||
let mut path = path_to(child, tag)?;
|
||||
path.insert(0, index);
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree {
|
||||
for &index in path {
|
||||
tree = &mut tree.children[index];
|
||||
}
|
||||
tree
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for size in [Size::new(1440., 900.), Size::new(1100., 700.)] {
|
||||
for tool in [Tool::Control, Tool::Branch, Tool::Connect] {
|
||||
let f = fixture(layer);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.nodes[1]));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = size;
|
||||
let original = app.document.snapshot();
|
||||
let graph: Element<'_, Message> =
|
||||
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
|
||||
let canvas_tag = Tree::new(graph.as_widget()).tag;
|
||||
drop(graph);
|
||||
let scroll: Element<'_, Message> = scrollable(text("")).into();
|
||||
let scroll_tag = Tree::new(scroll.as_widget()).tag;
|
||||
let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget());
|
||||
let mut scroll_path = path_to(&tree, canvas_tag).unwrap();
|
||||
scroll_path.pop();
|
||||
assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag);
|
||||
// Scrollable::diff treats its local state as opaque. A retained marker
|
||||
// proves the real Iced diff preserves the state that owns the offsets.
|
||||
// No layout or input operation runs on this test-only marker state.
|
||||
let marker = Rc::new(());
|
||||
at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker));
|
||||
let _ = app.update(Message::Behavior(super::super::Event::Topology(
|
||||
Event::Start(f.owner, tool, Some(f.nodes[1])),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
|
||||
assert_eq!(
|
||||
Rc::strong_count(&marker),
|
||||
2,
|
||||
"form open: {layer:?} {size:?} {tool:?}"
|
||||
);
|
||||
let mut current_path = path_to(&tree, canvas_tag).unwrap();
|
||||
current_path.pop();
|
||||
assert_eq!(current_path, scroll_path);
|
||||
assert!(Rc::ptr_eq(
|
||||
at(&mut tree, &scroll_path).state.downcast_ref::<Rc<()>>(),
|
||||
&marker,
|
||||
));
|
||||
let _ = app.update(Message::Behavior(super::super::Event::Topology(
|
||||
Event::Cancel,
|
||||
)));
|
||||
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
|
||||
assert_eq!(
|
||||
Rc::strong_count(&marker),
|
||||
2,
|
||||
"form cancel: {layer:?} {size:?} {tool:?}"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,18 +1447,15 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
for scale in [0.15, 0.6898551, 1.5] {
|
||||
let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice));
|
||||
graph.scale = scale;
|
||||
graph.camera_pan = iced::Vector::new(11., 19.);
|
||||
graph.scroll_offset = iced::Vector::new(11., 19.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(40., 70.),
|
||||
Size::new(
|
||||
graph.size.width * scale + 22.,
|
||||
graph.size.height * scale + 38.,
|
||||
),
|
||||
Size::new(graph.size.width * scale, graph.size.height * scale),
|
||||
);
|
||||
let screen = |world: Point| {
|
||||
Point::new(
|
||||
world.x * scale + bounds.x + 11.,
|
||||
world.y * scale + bounds.y + 19.,
|
||||
world.x * scale + bounds.x - 11.,
|
||||
world.y * scale + bounds.y - 19.,
|
||||
)
|
||||
};
|
||||
let edge = &graph.edges[0];
|
||||
@@ -1398,7 +1463,6 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
(edge.points[0].x + edge.points[1].x) / 2.,
|
||||
edge.points[0].y,
|
||||
));
|
||||
assert!(bounds.contains(point));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
@@ -1429,7 +1493,6 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
let mut input = Input::default();
|
||||
let a = screen(position(&graph, f.nodes[0]));
|
||||
let b = screen(position(&graph, f.nodes[2]));
|
||||
assert!(bounds.contains(a) && bounds.contains(b));
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,107 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
let pending = state.draft.is_some();
|
||||
let ordered = !legacy_order(p, owner).is_empty();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.style(if state.tool == CanvasTool::Select {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Select)),
|
||||
button("Work")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||||
button("Exchange")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||||
button("Reuse")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
pick_list(
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop
|
||||
]
|
||||
.into_iter()
|
||||
.map(ControlChoice)
|
||||
.collect::<Vec<_>>(),
|
||||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||||
Some(ControlChoice(kind))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Add(Tool::Control, c.0)
|
||||
))
|
||||
)
|
||||
.placeholder("Draw control…")
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
button("Sequence link")
|
||||
.style(if state.tool == CanvasTool::Connect {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Connect)),
|
||||
button("Two paths…").on_press(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
if !pending && ordered {
|
||||
controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas(
|
||||
CanvasEvent::AppendExchange(context),
|
||||
)));
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
controls = controls.push(
|
||||
button("Edit path links and guards")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -111,6 +211,11 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
);
|
||||
}
|
||||
}
|
||||
if !pending {
|
||||
controls = controls.push(button("Qualified link / assembly…").on_press(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
)));
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
@@ -151,79 +256,27 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
navigation = navigation
|
||||
.push(
|
||||
button("Two paths...").on_press_maybe(clean.then_some(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected,
|
||||
)))),
|
||||
)
|
||||
.push(
|
||||
button("Qualified link / assembly...").on_press_maybe(clean.then_some(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
))),
|
||||
let diagram = {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
if ordered {
|
||||
navigation = navigation.push(button("Append exchange...").on_press_maybe(clean.then_some(
|
||||
Message::TopologyCanvas(CanvasEvent::AppendExchange(context)),
|
||||
)));
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button("Edit path links and guards")
|
||||
.on_press_maybe(clean.then_some(Message::Command(unique_batch(p, commands)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.camera_pan = state.pan;
|
||||
graph.pending_insertion = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner && d.fixed)
|
||||
.map(|d| d.insertion);
|
||||
graph.interaction = Some((context, state.tool, clean));
|
||||
let scale = graph.scale;
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
};
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::TopologyCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
let canvas = container(
|
||||
iced::widget::sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(graph).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(state.tool == CanvasTool::Select),
|
||||
)
|
||||
.key((context.document, owner))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("process-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
let header = column![
|
||||
row![
|
||||
out.width(Fill),
|
||||
super::super::canvas_workspace::Control::new(
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
// Explicit graphs no longer have a scrolling page ancestor. A retained
|
||||
// offset from an ordered view must not distort their pointer coordinates.
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
let scale = graph.scale;
|
||||
let mut header = column![
|
||||
row![
|
||||
out.width(Fill),
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
@@ -232,352 +285,368 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
|
||||
.padding(6)
|
||||
.on_press(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::TopologyCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
scope,
|
||||
"Panels",
|
||||
keyboard,
|
||||
Some(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::TopologyCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
event(Event::Shelf(Shelf::Selection)),
|
||||
clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
))),
|
||||
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(4);
|
||||
let entries = [
|
||||
("S", "Select contributions or paths", CanvasTool::Select),
|
||||
(
|
||||
"W",
|
||||
"Work: click a path or empty space",
|
||||
CanvasTool::Add(Tool::Work, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"E",
|
||||
"Exchange: click a path or empty space",
|
||||
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"ref",
|
||||
"Reuse process / chain",
|
||||
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"OR",
|
||||
"Choice",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"M",
|
||||
"Merge",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Merge),
|
||||
),
|
||||
(
|
||||
"AND",
|
||||
"Fork",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Fork),
|
||||
),
|
||||
(
|
||||
"J",
|
||||
"Join",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Join),
|
||||
),
|
||||
(
|
||||
"IT",
|
||||
"Loop",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Loop),
|
||||
),
|
||||
(
|
||||
"->",
|
||||
"Sequence link: click or drag between contributions",
|
||||
CanvasTool::Connect,
|
||||
),
|
||||
];
|
||||
let palette = super::super::canvas_workspace::palette(
|
||||
scope,
|
||||
keyboard,
|
||||
camera,
|
||||
entries.into_iter().map(|(glyph, label, choice)| {
|
||||
(
|
||||
glyph,
|
||||
label,
|
||||
choice == state.tool,
|
||||
clean.then_some(Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
|
||||
.id("process-details").width(Fill).height(Fill).into();
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Work {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
|
||||
}
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Tools))),
|
||||
button(text("Selection details").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Selection))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(6);
|
||||
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
|
||||
}
|
||||
if graph.nodes.is_empty() {
|
||||
controls = controls.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
let canvas = scrollable(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let canvas = container(
|
||||
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
};
|
||||
explicit_pane(
|
||||
header.into(),
|
||||
details,
|
||||
canvas.into(),
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(d.invocation, actions.into(), form.into());
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
state.shelf.map(|_| details)
|
||||
};
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
/// A permanent three-slot tree: variable controls never remount the canvas.
|
||||
/// The shelf scrolls independently; the canvas receives actual remaining height.
|
||||
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: iced::Element<'a, Message, iced::Theme, R>,
|
||||
details: iced::Element<'a, Message, iced::Theme, R>,
|
||||
canvas: iced::Element<'a, Message, iced::Theme, R>,
|
||||
show_details: bool,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
|
||||
// slot so its disclosure cannot move/remount the following canvas tree.
|
||||
Column::from_vec(vec![
|
||||
container(header).width(Fill).into(),
|
||||
scrollable(details)
|
||||
.id("process-details")
|
||||
.width(Fill)
|
||||
.height(if show_details {
|
||||
if pending { 64. } else { 112. }
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.into(),
|
||||
container(canvas).width(Fill).height(Fill).into(),
|
||||
])
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
/// workspace height. The editor must never be clipped by the page scrollable.
|
||||
fn panes<'a, R: iced::advanced::Renderer + 'a>(
|
||||
diagram: iced::Element<'a, Message, iced::Theme, R>,
|
||||
form: iced::Element<'a, Message, iced::Theme, R>,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
column![
|
||||
container(diagram).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
Fill
|
||||
}),
|
||||
container(form).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
iced::Length::Shrink
|
||||
}),
|
||||
]
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
session: Id,
|
||||
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||
fields: iced::Element<'a, Message, iced::Theme, R>,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
container(
|
||||
container(
|
||||
column![
|
||||
container(actions).id("process-actions"),
|
||||
super::super::form_session::reset_on_change(
|
||||
session,
|
||||
scrollable(fields).id("process-form").height(Fill)
|
||||
)
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct Document {
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
current_bytes: usize,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
saved: Arc<SourceBundle>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
@@ -125,7 +125,7 @@ impl Document {
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
saved: Some(Arc::new(bundle)),
|
||||
saved: Arc::new(bundle),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
@@ -156,7 +156,7 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
||||
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
|
||||
}
|
||||
|
||||
/// Recovered work is a detached copy until explicitly saved by its user.
|
||||
@@ -181,24 +181,10 @@ impl Document {
|
||||
/// Records the bytes actually saved, even when later edits remain current.
|
||||
/// The I/O adapter owns document identity and save completion ordering.
|
||||
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
||||
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
||||
self.saved = Arc::new(snapshot.bundle.clone());
|
||||
self.recovered_unsaved = false;
|
||||
}
|
||||
|
||||
/// Bind an installed or recovered repair destination to source read there.
|
||||
/// These bytes may be invalid or differ from the retained accepted model,
|
||||
/// but must replace the previous disk baseline. Accepted history is retained;
|
||||
/// applying or undoing to old saved content does not save this destination.
|
||||
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
||||
self.saved = Some(Arc::new(source));
|
||||
}
|
||||
|
||||
/// Recovery changed the current destination, but its source could not be
|
||||
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
||||
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
||||
self.saved = None;
|
||||
}
|
||||
|
||||
/// Check a source Save as before writing any files. A retained included file
|
||||
/// must never be displaced by the new entry, including on a later Undo.
|
||||
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
||||
|
||||
@@ -18,7 +18,6 @@ mod context_fields;
|
||||
mod data_fields;
|
||||
mod history_fields;
|
||||
mod port_fields;
|
||||
pub(crate) mod reference_chooser;
|
||||
mod view_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -86,8 +85,6 @@ pub struct Draft {
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
|
||||
history: history_fields::State,
|
||||
reference_session: Id,
|
||||
reference_generation: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -212,14 +209,11 @@ impl Draft {
|
||||
value_browsers: BTreeMap::new(),
|
||||
reference_browsers: BTreeMap::new(),
|
||||
history: Default::default(),
|
||||
reference_session: Id::new_v4(),
|
||||
reference_generation: Id::new_v4(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
self.reference_generation = Id::new_v4();
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
@@ -280,9 +274,6 @@ impl Draft {
|
||||
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
|
||||
}
|
||||
pub fn set_context(&mut self, context: Option<Value>) {
|
||||
if self.context != context {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
self.context = context;
|
||||
}
|
||||
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||
@@ -368,9 +359,6 @@ impl Draft {
|
||||
}
|
||||
pub fn input(&mut self, path: &str, value: String) {
|
||||
if let Some(input) = self.inputs.get_mut(path) {
|
||||
if input.raw != value {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
input.editor = text_editor::Content::with_text(&value);
|
||||
input.raw = value;
|
||||
}
|
||||
@@ -378,11 +366,7 @@ impl Draft {
|
||||
pub fn edit_text(&mut self, path: &str, action: text_editor::Action) {
|
||||
if let Some(input) = self.inputs.get_mut(path) {
|
||||
input.editor.perform(action);
|
||||
let raw = input.editor.text();
|
||||
if input.raw != raw {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
input.raw = raw;
|
||||
input.raw = input.editor.text();
|
||||
}
|
||||
}
|
||||
pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
@@ -1811,7 +1795,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
));
|
||||
}
|
||||
}
|
||||
reference_chooser::trigger::scope(content, draft.reference_session)
|
||||
content.into()
|
||||
}
|
||||
|
||||
/// Render one closed property subtree for a workflow-owned typed draft.
|
||||
@@ -1953,12 +1937,41 @@ fn property<'a>(
|
||||
return out.into();
|
||||
}
|
||||
if reference(path) && (value.is_string() || value.is_null()) {
|
||||
return column![
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| value.as_str());
|
||||
let current = raw.and_then(|s| s.parse::<Id>().ok()).unwrap_or(Id::nil());
|
||||
let choices = reference_choices(draft, project, path, current);
|
||||
let selected = choices.iter().find(|c| c.id == current).cloned();
|
||||
let owned_path = path.to_string();
|
||||
let optional = optional_reference(path);
|
||||
let mut content = column![
|
||||
label_view(),
|
||||
reference_chooser::adapter::field(draft, project, path)
|
||||
pick_list(choices, selected, move |choice| Message::FieldShape(
|
||||
owned_path.clone(),
|
||||
if choice.id.is_nil() && optional {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(choice.id)
|
||||
}
|
||||
))
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(5)
|
||||
.into();
|
||||
.spacing(5);
|
||||
if let Some(raw) = raw
|
||||
&& raw.parse::<Id>().is_err()
|
||||
{
|
||||
content = content.push(
|
||||
text(format!("Invalid reference retained in the draft: {raw}"))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
return content.into();
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
|
||||
@@ -670,7 +670,7 @@ mod tests {
|
||||
}
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) {
|
||||
let event = semantic_state(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(event)));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
}
|
||||
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
|
||||
use crate::semantic_review::RowEvent as Event;
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
//! Isolated live-reference chooser experiment, declared only under cfg(test).
|
||||
//! The host supplies an eligible catalog and revalidates every
|
||||
//! Write intent against the current document before changing a staged scalar.
|
||||
//! No core command, draft serialization, or App message belongs in this module.
|
||||
use oa_core::Id;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub mod adapter;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod trigger;
|
||||
pub mod widget;
|
||||
|
||||
pub const MAX_ROWS: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub revision: u64,
|
||||
pub draft: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub optional: bool,
|
||||
}
|
||||
impl Context {
|
||||
/// This is an additional historical-path guard, not live schema discovery.
|
||||
/// Call only after the parent form has identified a live reference field.
|
||||
pub fn live(
|
||||
document: Id,
|
||||
revision: u64,
|
||||
draft: Id,
|
||||
record: Id,
|
||||
path: impl Into<String>,
|
||||
optional: bool,
|
||||
) -> Result<Self, &'static str> {
|
||||
let path = path.into();
|
||||
if !path.starts_with('/') || path.split('/').any(|part| part == "baseline") {
|
||||
return Err("The chooser requires a live reference property.");
|
||||
}
|
||||
Ok(Self {
|
||||
document,
|
||||
revision,
|
||||
draft,
|
||||
record,
|
||||
path,
|
||||
optional,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
/// For a diagram placement this is the placement ID, not its element ID.
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub alias: String,
|
||||
pub kind: String,
|
||||
pub scope: String,
|
||||
pub containment: String,
|
||||
}
|
||||
impl Candidate {
|
||||
pub fn context_label(&self) -> String {
|
||||
format!("{} · {} · {}", self.scope, self.kind, self.containment)
|
||||
}
|
||||
pub fn detail(&self) -> String {
|
||||
format!(
|
||||
"{} · @{} · {} · {}",
|
||||
self.name,
|
||||
self.alias,
|
||||
self.context_label(),
|
||||
self.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Current {
|
||||
Unassigned,
|
||||
Valid(Candidate),
|
||||
Incompatible(Candidate),
|
||||
Missing(Id),
|
||||
InvalidRaw(String),
|
||||
}
|
||||
impl Current {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Unassigned => "Not assigned".into(),
|
||||
Self::Valid(c) => format!("Current: {} · @{}", c.name, c.alias),
|
||||
Self::Incompatible(c) => {
|
||||
format!("Current choice needs review: {} · @{}", c.name, c.alias)
|
||||
}
|
||||
Self::Missing(id) => format!("Missing reference: {id}"),
|
||||
Self::InvalidRaw(raw) => format!("Invalid reference retained: {raw}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Searchable {
|
||||
candidate: Candidate,
|
||||
all: String,
|
||||
alias: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Stamp {
|
||||
pub context: Context,
|
||||
pub invocation: Id,
|
||||
pub results: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Close {
|
||||
Escape,
|
||||
Tab { backwards: bool },
|
||||
Outside,
|
||||
WindowChanged,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The mounted text input numbers emitted edits. Multiple edits in one
|
||||
/// runtime batch may share the old result stamp; none may be dropped.
|
||||
Query {
|
||||
text: String,
|
||||
serial: u64,
|
||||
},
|
||||
Move {
|
||||
delta: i32,
|
||||
rows: usize,
|
||||
},
|
||||
Page {
|
||||
delta: i32,
|
||||
rows: usize,
|
||||
},
|
||||
Highlight(Id),
|
||||
Choose(Id),
|
||||
Clear,
|
||||
Close(Close),
|
||||
}
|
||||
impl Action {
|
||||
pub fn writes_staged_value(&self) -> bool {
|
||||
matches!(self, Self::Choose(_) | Self::Clear)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Event {
|
||||
pub stamp: Stamp,
|
||||
pub action: Action,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Ignored,
|
||||
Browsed,
|
||||
Closed(Close),
|
||||
/// The host must still check current eligibility and scoped draft guards.
|
||||
Write {
|
||||
context: Context,
|
||||
value: Option<Id>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
context: Context,
|
||||
invocation: Id,
|
||||
results: u64,
|
||||
serial: u64,
|
||||
open: bool,
|
||||
candidates: Vec<Searchable>,
|
||||
filtered: Vec<usize>,
|
||||
query: String,
|
||||
start: usize,
|
||||
highlight: Option<usize>,
|
||||
pub current: Current,
|
||||
pub scope_label: String,
|
||||
}
|
||||
impl State {
|
||||
pub fn open(
|
||||
context: Context,
|
||||
candidates: Vec<Candidate>,
|
||||
current: Current,
|
||||
scope_label: impl Into<String>,
|
||||
) -> Result<Self, &'static str> {
|
||||
// Context fields are public for host comparisons; check again in case
|
||||
// a caller constructed one without the live-path constructor.
|
||||
if !context.path.starts_with('/') || context.path.split('/').any(|p| p == "baseline") {
|
||||
return Err("Historical values are not live reference candidates.");
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut indexed = Vec::with_capacity(candidates.len());
|
||||
for candidate in candidates {
|
||||
if candidate.id.is_nil() || !seen.insert(candidate.id) {
|
||||
return Err("Eligible references must have distinct, non-nil identities.");
|
||||
}
|
||||
indexed.push(Searchable {
|
||||
all: candidate.detail().to_lowercase(),
|
||||
alias: candidate.alias.to_lowercase(),
|
||||
name: candidate.name.to_lowercase(),
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
indexed.sort_by(|a, b| {
|
||||
(&a.name, &a.alias, a.candidate.id).cmp(&(&b.name, &b.alias, b.candidate.id))
|
||||
});
|
||||
let filtered: Vec<_> = (0..indexed.len()).collect();
|
||||
let highlight = (!filtered.is_empty()).then_some(0);
|
||||
Ok(Self {
|
||||
context,
|
||||
invocation: Id::new_v4(),
|
||||
results: 0,
|
||||
serial: 0,
|
||||
open: true,
|
||||
candidates: indexed,
|
||||
filtered,
|
||||
query: String::new(),
|
||||
start: 0,
|
||||
highlight,
|
||||
current,
|
||||
scope_label: scope_label.into(),
|
||||
})
|
||||
}
|
||||
pub fn stamp(&self) -> Stamp {
|
||||
Stamp {
|
||||
context: self.context.clone(),
|
||||
invocation: self.invocation,
|
||||
results: self.results,
|
||||
}
|
||||
}
|
||||
pub fn event(&self, action: Action) -> Event {
|
||||
Event {
|
||||
stamp: self.stamp(),
|
||||
action,
|
||||
}
|
||||
}
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
pub fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
pub fn query_serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
pub fn count(&self) -> usize {
|
||||
self.filtered.len()
|
||||
}
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
pub fn optional(&self) -> bool {
|
||||
self.context.optional
|
||||
}
|
||||
pub fn highlighted(&self) -> Option<&Candidate> {
|
||||
self.highlight.and_then(|i| self.at(i))
|
||||
}
|
||||
fn at(&self, index: usize) -> Option<&Candidate> {
|
||||
self.filtered
|
||||
.get(index)
|
||||
.map(|i| &self.candidates[*i].candidate)
|
||||
}
|
||||
pub fn visible(&self, rows: usize) -> impl Iterator<Item = &Candidate> {
|
||||
self.filtered
|
||||
.iter()
|
||||
.skip(self.start)
|
||||
.take(rows.min(MAX_ROWS))
|
||||
.map(|index| &self.candidates[*index].candidate)
|
||||
}
|
||||
/// The host calls this before rendering on any path/document/facet/context
|
||||
/// replacement. Existing events remain tied to the old invocation.
|
||||
pub fn invalidate(&mut self) {
|
||||
self.open = false;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: Event) -> Outcome {
|
||||
if !self.open
|
||||
|| event.stamp.context != self.context
|
||||
|| event.stamp.invocation != self.invocation
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
// Absolute row actions require exactly the presented result set.
|
||||
// Relative navigation and dismissal are processed in input order within
|
||||
// this session: typing must not make a following Escape stale.
|
||||
if matches!(event.action, Action::Choose(_) | Action::Highlight(_))
|
||||
&& event.stamp.results != self.results
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
match event.action {
|
||||
Action::Query { text, serial } => {
|
||||
if serial <= self.serial {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.serial = serial;
|
||||
self.query = text;
|
||||
let query = self.query.to_lowercase();
|
||||
let terms: Vec<_> = query.split_whitespace().collect();
|
||||
self.filtered = self
|
||||
.candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
terms
|
||||
.iter()
|
||||
.all(|term| match term.strip_prefix('@') {
|
||||
Some(alias) => c.alias.contains(alias),
|
||||
None => c.all.contains(term),
|
||||
})
|
||||
.then_some(i)
|
||||
})
|
||||
.collect();
|
||||
self.start = 0;
|
||||
self.highlight = (!self.filtered.is_empty()).then_some(0);
|
||||
}
|
||||
Action::Page { delta, rows } => {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
if self.filtered.is_empty() {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
let relative = self
|
||||
.highlight
|
||||
.unwrap_or(self.start)
|
||||
.saturating_sub(self.start)
|
||||
.min(rows - 1);
|
||||
let last_page = (self.filtered.len() - 1) / rows * rows;
|
||||
self.start = (self.start as i64 + i64::from(delta) * rows as i64)
|
||||
.clamp(0, last_page as i64) as usize;
|
||||
self.highlight = Some((self.start + relative).min(self.filtered.len() - 1));
|
||||
}
|
||||
Action::Move { delta, rows } => {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
if self.filtered.is_empty() {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
let current = self.highlight.unwrap_or(self.start) as i64;
|
||||
let next =
|
||||
(current + i64::from(delta)).clamp(0, self.filtered.len() as i64 - 1) as usize;
|
||||
self.highlight = Some(next);
|
||||
if next < self.start {
|
||||
self.start = next;
|
||||
}
|
||||
if next >= self.start + rows {
|
||||
self.start = next + 1 - rows;
|
||||
}
|
||||
}
|
||||
Action::Highlight(id) => {
|
||||
let Some(index) = self
|
||||
.filtered
|
||||
.iter()
|
||||
.position(|i| self.candidates[*i].candidate.id == id)
|
||||
else {
|
||||
return Outcome::Ignored;
|
||||
};
|
||||
if self.highlight == Some(index) {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.highlight = Some(index);
|
||||
}
|
||||
Action::Choose(id) => {
|
||||
if !self
|
||||
.filtered
|
||||
.iter()
|
||||
.any(|i| self.candidates[*i].candidate.id == id)
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.open = false;
|
||||
return Outcome::Write {
|
||||
context: self.context.clone(),
|
||||
value: Some(id),
|
||||
};
|
||||
}
|
||||
Action::Clear if self.context.optional => {
|
||||
self.open = false;
|
||||
return Outcome::Write {
|
||||
context: self.context.clone(),
|
||||
value: None,
|
||||
};
|
||||
}
|
||||
Action::Clear => return Outcome::Ignored,
|
||||
Action::Close(reason) => {
|
||||
self.open = false;
|
||||
return Outcome::Closed(reason);
|
||||
}
|
||||
}
|
||||
self.results = self.results.wrapping_add(1);
|
||||
Outcome::Browsed
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
//! Live form adapter. Compatibility stays in the existing parent adapter;
|
||||
//! browser metadata and scoped writes never reinterpret historical UUID text.
|
||||
use super::super::{Draft, friendly, history_fields, optional_reference, reference, reference_ids};
|
||||
use super::{Candidate, Context, Current, State, trigger};
|
||||
use crate::{app::Message, style};
|
||||
use iced::{
|
||||
Element, Fill, Rectangle,
|
||||
widget::{column, container, row, text, tooltip},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Open {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub generation: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub anchor: Rectangle,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Set {
|
||||
pub context: Context,
|
||||
pub generation: Id,
|
||||
pub value: Option<Id>,
|
||||
}
|
||||
impl Draft {
|
||||
pub fn reference_session(&self) -> Id {
|
||||
self.reference_session
|
||||
}
|
||||
pub fn reference_generation(&self) -> Id {
|
||||
self.reference_generation
|
||||
}
|
||||
pub fn reference_open(&self, project: &Project, path: &str, anchor: Rectangle) -> Open {
|
||||
Open {
|
||||
document: project.id,
|
||||
session: self.reference_session,
|
||||
generation: self.reference_generation,
|
||||
record: self.original,
|
||||
path: path.into(),
|
||||
anchor,
|
||||
}
|
||||
}
|
||||
pub fn reference_matches(&self, request: &Open) -> bool {
|
||||
self.reference_session == request.session
|
||||
&& self.reference_generation == request.generation
|
||||
&& self.original == request.record
|
||||
&& self.live_reference(&request.path)
|
||||
}
|
||||
fn live_reference(&self, path: &str) -> bool {
|
||||
!history_fields::is_path(path)
|
||||
&& reference(path)
|
||||
&& self
|
||||
.value
|
||||
.pointer(path)
|
||||
.is_some_and(|v| v.is_string() || v.is_null())
|
||||
}
|
||||
pub fn reference_catalog(
|
||||
&self,
|
||||
project: &Project,
|
||||
request: &Open,
|
||||
revision: u64,
|
||||
) -> Result<State, String> {
|
||||
if request.document != project.id || !self.reference_matches(request) {
|
||||
return Err("The reference field changed. Open its chooser again.".into());
|
||||
}
|
||||
let allowed: BTreeSet<_> = reference_ids(self, project, &request.path)
|
||||
.into_iter()
|
||||
.filter(|id| !id.is_nil())
|
||||
.collect();
|
||||
let candidates = allowed
|
||||
.iter()
|
||||
.filter_map(|id| candidate(project, *id))
|
||||
.collect();
|
||||
let raw = self
|
||||
.inputs
|
||||
.get(&request.path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| self.value.pointer(&request.path).and_then(Value::as_str));
|
||||
let current = match raw {
|
||||
None => Current::Unassigned,
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => Current::Unassigned,
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) if allowed.contains(&id) => Current::Valid(c),
|
||||
Some(c) => Current::Incompatible(c),
|
||||
None => Current::Missing(id),
|
||||
},
|
||||
Err(_) => Current::InvalidRaw(raw.into()),
|
||||
},
|
||||
};
|
||||
let context = Context::live(
|
||||
project.id,
|
||||
revision,
|
||||
self.reference_session,
|
||||
self.original,
|
||||
&request.path,
|
||||
optional_reference(&request.path),
|
||||
)
|
||||
.map_err(str::to_owned)?;
|
||||
State::open(
|
||||
context,
|
||||
candidates,
|
||||
current,
|
||||
format!(
|
||||
"{} · compatible references",
|
||||
friendly(request.path.rsplit('/').next().unwrap_or("Reference"))
|
||||
),
|
||||
)
|
||||
.map_err(str::to_owned)
|
||||
}
|
||||
pub fn reference_set(&mut self, project: &Project, intent: &Set) -> Result<(), String> {
|
||||
let value = self.reference_value(project, intent)?;
|
||||
self.shape_scoped(&intent.context.path, value)
|
||||
}
|
||||
pub fn reference_value(&self, project: &Project, intent: &Set) -> Result<Value, String> {
|
||||
let context = &intent.context;
|
||||
if context.document != project.id
|
||||
|| context.draft != self.reference_session
|
||||
|| context.record != self.original
|
||||
|| intent.generation != self.reference_generation
|
||||
|| !self.live_reference(&context.path)
|
||||
|| context.optional != optional_reference(&context.path)
|
||||
{
|
||||
return Err("The originating reference field is no longer current.".into());
|
||||
}
|
||||
let value = match intent.value {
|
||||
Some(id)
|
||||
if !id.is_nil() && reference_ids(self, project, &context.path).contains(&id) =>
|
||||
{
|
||||
json!(id)
|
||||
}
|
||||
None if context.optional => Value::Null,
|
||||
_ => return Err("Choose a currently compatible reference.".into()),
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn named(project: &Project, id: Id) -> String {
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
format!("{} · @{}", e.name, e.alias)
|
||||
} else {
|
||||
project.named(id).into()
|
||||
}
|
||||
}
|
||||
fn ancestry(project: &Project, id: Id) -> String {
|
||||
let mut parts = vec![];
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
parts.push("[containment cycle]".into());
|
||||
break;
|
||||
}
|
||||
parts.push(named(project, id));
|
||||
current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
parts.reverse();
|
||||
if parts.is_empty() {
|
||||
project.name.clone()
|
||||
} else {
|
||||
parts.join(" / ")
|
||||
}
|
||||
}
|
||||
pub(crate) fn candidate(project: &Project, id: Id) -> Option<Candidate> {
|
||||
let layer = |layer| format!("{layer:?}").to_uppercase();
|
||||
if id == project.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.name.clone(),
|
||||
alias: project.alias.clone(),
|
||||
kind: "Project".into(),
|
||||
scope: "Model".into(),
|
||||
containment: "Model root".into(),
|
||||
});
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: e.name.clone(),
|
||||
alias: e.alias.clone(),
|
||||
kind: e.kind().to_string(),
|
||||
scope: layer(e.layer()),
|
||||
containment: ancestry(project, id),
|
||||
});
|
||||
}
|
||||
if let Some(r) = project.relations.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: r.name.clone(),
|
||||
alias: r.alias.clone(),
|
||||
kind: r.kind().to_string(),
|
||||
scope: project.layer_of(id).map_or_else(|| "Model".into(), layer),
|
||||
containment: format!(
|
||||
"{} → {}",
|
||||
named(project, r.source()),
|
||||
named(project, r.target())
|
||||
),
|
||||
});
|
||||
}
|
||||
for diagram in project.diagrams.values() {
|
||||
if id == diagram.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: diagram.name.clone(),
|
||||
alias: diagram.alias.clone(),
|
||||
kind: format!("{:?} diagram", diagram.projection),
|
||||
scope: layer(diagram.layer),
|
||||
containment: project.name.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(node) = diagram.nodes.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.named(node.element).into(),
|
||||
alias: node.alias.clone(),
|
||||
kind: "Placement".into(),
|
||||
scope: layer(diagram.layer),
|
||||
containment: format!(
|
||||
"{} · @{} / {}",
|
||||
diagram.name,
|
||||
diagram.alias,
|
||||
named(project, node.element)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(link) = project.artifact_links.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: link.name.clone(),
|
||||
alias: link.alias.clone(),
|
||||
kind: "Artifact link".into(),
|
||||
scope: project
|
||||
.layer_of(link.owner)
|
||||
.map_or_else(|| "Model".into(), layer),
|
||||
containment: named(project, link.owner),
|
||||
});
|
||||
}
|
||||
project.traces.get(&id).map(|trace| Candidate {
|
||||
id,
|
||||
name: trace.name.clone(),
|
||||
alias: trace.alias.clone(),
|
||||
kind: "Trace".into(),
|
||||
scope: "OA ↔ SA".into(),
|
||||
containment: format!(
|
||||
"{} ↔ {}",
|
||||
named(project, trace.oa),
|
||||
named(project, trace.sa)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn field<'a, R: iced::advanced::text::Renderer<Font = iced::Font> + 'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
path: &str,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| draft.value.pointer(path).and_then(Value::as_str));
|
||||
let (label, detail) = match raw {
|
||||
None => (
|
||||
if optional_reference(path) {
|
||||
"Not assigned"
|
||||
} else {
|
||||
"Choose a reference…"
|
||||
}
|
||||
.into(),
|
||||
String::new(),
|
||||
),
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => ("Choose a reference…".into(), String::new()),
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) => (format!("{} · @{}", c.name, c.alias), c.detail()),
|
||||
None => ("Missing reference".into(), raw.into()),
|
||||
},
|
||||
Err(_) => ("Invalid reference retained".into(), raw.into()),
|
||||
},
|
||||
};
|
||||
let label = crate::views::short(&label, 35);
|
||||
let body = container(row![text(label).size(12).width(Fill), text("⌄").size(12)].spacing(6))
|
||||
.padding(8)
|
||||
.width(Fill);
|
||||
let path = path.to_owned();
|
||||
let id = trigger::field_id(draft.reference_generation, &path);
|
||||
let field = trigger::Field::new(body, id, move |anchor| {
|
||||
Message::FieldReferenceOpen(draft.reference_open(project, &path, anchor))
|
||||
});
|
||||
if detail.is_empty() {
|
||||
field.into()
|
||||
} else {
|
||||
tooltip(
|
||||
field,
|
||||
container(column![text(detail).size(11)])
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
tooltip::Position::Left,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -1,803 +0,0 @@
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{Layout, Shell, layout, widget::Tree},
|
||||
keyboard, mouse,
|
||||
widget::{Space, mouse_area},
|
||||
};
|
||||
|
||||
fn context(optional: bool) -> Context {
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
7,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
"/body/SequenceUse/sequence",
|
||||
optional,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn candidate(index: u128) -> Candidate {
|
||||
Candidate {
|
||||
id: Id::from_u128(10_000 + index),
|
||||
name: "Control".into(),
|
||||
alias: format!("controller_{index:04}"),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: format!("System / Subsystem {} / Controller", index / 100),
|
||||
}
|
||||
}
|
||||
fn thousand(current: Current, optional: bool) -> State {
|
||||
// Reverse the fixture input to falsify accidental dependence on ingestion
|
||||
// order. Expected IDs and aliases below are literal fixture identities.
|
||||
State::open(
|
||||
context(optional),
|
||||
(0..1_000).rev().map(candidate).collect(),
|
||||
current,
|
||||
"SA · compatible functions",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Chooser(Event),
|
||||
UnderPress,
|
||||
UnderRelease,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard(Option<String>);
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
self.0.clone()
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
|
||||
self.0 = Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
anchor: Rectangle,
|
||||
clipboard: Clipboard,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(1100., 700.),
|
||||
anchor: Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
clipboard: Clipboard::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn batch(
|
||||
&mut self,
|
||||
state: Option<&State>,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Option<layout::Node>) {
|
||||
let underneath: Element<'_, Message, Theme, ()> =
|
||||
mouse_area(Space::new().width(iced::Fill).height(iced::Fill))
|
||||
.on_press(Message::UnderPress)
|
||||
.on_release(Message::UnderRelease)
|
||||
.into();
|
||||
let mut view: Element<'_, Message, Theme, ()> =
|
||||
widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into();
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (event, point) in events {
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
// Reconstruct the overlay between layout and every input, as
|
||||
// Iced does. Geometry must live in the measured layout tree.
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
event,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
overlay.as_overlay().draw(
|
||||
&mut (),
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: iced::Color::BLACK,
|
||||
},
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
);
|
||||
if matches!(
|
||||
event,
|
||||
Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_)
|
||||
) {
|
||||
assert!(shell.is_event_captured(), "An open chooser owns input");
|
||||
}
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
(messages, overlay_node)
|
||||
}
|
||||
fn send(&mut self, state: &mut State, event: Input) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &[(event, None)]);
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Popup leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn type_text(&mut self, state: &mut State, text: &str) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &typing(text));
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Typing leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn geometry(&mut self, state: &State) -> widget::Geometry {
|
||||
let (_, node) = self.batch(Some(state), &[]);
|
||||
widget::Geometry::from_bounds(node.unwrap().children()[0].bounds())
|
||||
}
|
||||
fn click(&mut self, state: &State, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
Some(state),
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
}
|
||||
|
||||
fn named(key: keyboard::key::Named) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
// Iced 0.14's physical-key path intentionally inserts one scalar from text.
|
||||
// Whole-string typing is several key events; paste and IME commit are separate
|
||||
// input paths and are exercised below rather than faked as one physical key.
|
||||
fn typed(character: char) -> Input {
|
||||
let text = character.to_string();
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.clone().into()),
|
||||
modified_key: keyboard::Key::Character(text.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(text: &str) -> Vec<(Input, Option<Point>)> {
|
||||
text.chars()
|
||||
.map(|character| (typed(character), None))
|
||||
.collect()
|
||||
}
|
||||
fn query(state: &mut State, value: &str) {
|
||||
let event = state.event(Action::Query {
|
||||
text: value.into(),
|
||||
serial: state.query_serial() + 1,
|
||||
});
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
assert_eq!(
|
||||
state.visible(6).map(|c| c.id).collect::<Vec<_>>(),
|
||||
(10_000..10_006).map(Id::from_u128).collect::<Vec<_>>()
|
||||
);
|
||||
query(&mut state, "control subsystem 6 @controller_0617");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617));
|
||||
query(&mut state, "OA");
|
||||
assert_eq!(state.count(), 0);
|
||||
query(&mut state, &Id::from_u128(10_617).to_string());
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().alias, "controller_0617");
|
||||
|
||||
let placement = Candidate {
|
||||
id: Id::from_u128(20_001),
|
||||
name: "Control".into(),
|
||||
alias: "control_placement".into(),
|
||||
kind: "Placement".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Architecture / Main diagram / Control".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![placement],
|
||||
Current::Unassigned,
|
||||
"SA · placements",
|
||||
)
|
||||
.unwrap();
|
||||
query(&mut state, "placement main");
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001));
|
||||
let event = state.event(Action::Choose(Id::from_u128(20_000)));
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Ignored,
|
||||
"Semantic ID is not the placement identity"
|
||||
);
|
||||
assert!(
|
||||
State::open(
|
||||
context(false),
|
||||
vec![candidate(1), candidate(1)],
|
||||
Current::Unassigned,
|
||||
"SA"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() {
|
||||
let state = thousand(Current::InvalidRaw("not a uuid".into()), true);
|
||||
for size in [
|
||||
Size::new(1100., 700.),
|
||||
Size::new(1600., 1000.),
|
||||
Size::new(320., 320.),
|
||||
] {
|
||||
for y in [5., size.height / 2., size.height - 34.] {
|
||||
let mut mount = Mount {
|
||||
size,
|
||||
anchor: Rectangle {
|
||||
x: size.width - 300.,
|
||||
y,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let (_, node) = mount.batch(Some(&state), &[]);
|
||||
let node = node.unwrap();
|
||||
let bounds = node.children()[0].bounds();
|
||||
let geometry = widget::Geometry::from_bounds(bounds);
|
||||
assert!(bounds.x >= 8. && bounds.y >= 8.);
|
||||
assert!(bounds.x + bounds.width <= size.width - 8.);
|
||||
assert!(bounds.y + bounds.height <= size.height - 8.);
|
||||
assert!(bounds.width <= 420. && bounds.height <= 360.);
|
||||
assert!(geometry.rows > 0 && geometry.rows <= 6);
|
||||
assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 });
|
||||
let search = node.children()[1].bounds();
|
||||
assert!(
|
||||
bounds.contains(search.position())
|
||||
&& bounds.contains(Point::new(
|
||||
search.x + search.width,
|
||||
search.y + search.height
|
||||
))
|
||||
);
|
||||
assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y);
|
||||
assert_eq!(state.visible(geometry.rows).count(), geometry.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
2,
|
||||
"Actual focused input must publish each edit"
|
||||
);
|
||||
let mut queries = Vec::new();
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Unexpected parent event")
|
||||
};
|
||||
let Action::Query { text, .. } = &event.action else {
|
||||
panic!("Typing must not choose a reference")
|
||||
};
|
||||
queries.push(text.clone());
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(queries, ["a", "ab"]);
|
||||
assert_eq!(state.query(), "ab");
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Home))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]);
|
||||
assert_eq!(
|
||||
state.query(),
|
||||
"zab",
|
||||
"Rebuilding the controlled widget must not refocus at the end"
|
||||
);
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::PageDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.start(), 6);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006));
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::ArrowDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages.into_iter().next().unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(event.action.writes_staged_value());
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_007))
|
||||
}
|
||||
);
|
||||
assert!(!state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() {
|
||||
let mut state = thousand(Current::Missing(Id::from_u128(99)), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0731";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(state.count(), 1);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_731))
|
||||
}
|
||||
);
|
||||
assert_eq!(state.current, Current::Missing(Id::from_u128(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&state).row(4).center();
|
||||
let captured = mount.click(&state, point);
|
||||
let Message::Chooser(old_choice) = captured[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
query(&mut state, "@controller_0700");
|
||||
assert_eq!(state.update(old_choice), Outcome::Ignored);
|
||||
// The rejected old Choose had marked the widget closing; a changed result
|
||||
// context must unlock it so an explicit current click can complete.
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_700))
|
||||
}
|
||||
);
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
query(&mut state, "@controller_0319");
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() {
|
||||
let source = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&source).row(0).center();
|
||||
let Message::Chooser(event) = mount.click(&source, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
for changed in 0..6 {
|
||||
let mut destination = context(false);
|
||||
match changed {
|
||||
0 => destination.document = Id::from_u128(999),
|
||||
1 => destination.revision += 1,
|
||||
2 => destination.draft = Id::from_u128(999),
|
||||
3 => destination.record = Id::from_u128(999),
|
||||
4 => destination.path = "/body/SomeList/members/1/scenario".into(),
|
||||
_ => {} // A new invocation even with every semantic ID unchanged.
|
||||
}
|
||||
let mut state = State::open(
|
||||
destination,
|
||||
vec![candidate(0)],
|
||||
Current::InvalidRaw("unfinished".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.update(event.clone()), Outcome::Ignored);
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
let mut invalidated = source.clone();
|
||||
invalidated.invalidate();
|
||||
assert_eq!(invalidated.update(event), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() {
|
||||
for current in [
|
||||
Current::Missing(Id::from_u128(999)),
|
||||
Current::InvalidRaw("@incomplete/".into()),
|
||||
Current::Incompatible(candidate(999)),
|
||||
] {
|
||||
let mut state = thousand(current.clone(), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "no match exists";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(
|
||||
state.count(),
|
||||
0,
|
||||
"Verify actual input reached the empty-result state before Enter"
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Enter))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
assert_eq!(state.current, current);
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Escape)),
|
||||
[Outcome::Closed(Close::Escape)]
|
||||
);
|
||||
assert_eq!(state.current, current);
|
||||
}
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Tab)),
|
||||
[Outcome::Closed(Close::Tab { backwards: false })]
|
||||
);
|
||||
let mut state = thousand(Current::InvalidRaw("?".into()), true);
|
||||
let point = mount.geometry(&state).clear.center();
|
||||
let Message::Chooser(event) = mount.click(&state, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(true),
|
||||
value: None
|
||||
}
|
||||
);
|
||||
let mut required = thousand(Current::Unassigned, false);
|
||||
let clear = required.event(Action::Clear);
|
||||
assert_eq!(required.update(clear), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let outside = Point::new(20., 20.);
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside),
|
||||
)],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Expected only popup dismissal")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside));
|
||||
let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
|
||||
assert!(
|
||||
mount
|
||||
.batch(None, &[(release.clone(), Some(outside))])
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
mount
|
||||
.batch(
|
||||
None,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside)
|
||||
),
|
||||
(release, Some(outside)),
|
||||
]
|
||||
)
|
||||
.0,
|
||||
[Message::UnderPress, Message::UnderRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() {
|
||||
for path in [
|
||||
"/baseline",
|
||||
"/baseline/oa/value",
|
||||
"/baseline/sa/value/TypeRef/Named",
|
||||
] {
|
||||
assert!(
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
0,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
path,
|
||||
true
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut forged = context(true);
|
||||
forged.path = path.into();
|
||||
assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err());
|
||||
}
|
||||
for action in [
|
||||
Action::Query {
|
||||
text: "Control".into(),
|
||||
serial: 1,
|
||||
},
|
||||
Action::Highlight(Id::from_u128(10_000)),
|
||||
Action::Move { delta: 1, rows: 6 },
|
||||
Action::Page { delta: 1, rows: 6 },
|
||||
Action::Close(Close::Escape),
|
||||
] {
|
||||
assert!(!action.writes_staged_value());
|
||||
}
|
||||
assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value());
|
||||
assert!(Action::Clear.writes_staged_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_query_serials_cannot_restore_obsolete_results() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old = state.event(Action::Query {
|
||||
text: "@controller_0001".into(),
|
||||
serial: 1,
|
||||
});
|
||||
let new = state.event(Action::Query {
|
||||
text: "@controller_0999".into(),
|
||||
serial: 2,
|
||||
});
|
||||
assert_eq!(state.update(new), Outcome::Browsed);
|
||||
assert_eq!(state.update(old), Outcome::Ignored);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() {
|
||||
let mut state = thousand(Current::InvalidRaw("unfinished".into()), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0720";
|
||||
let mut inputs = typing(query);
|
||||
inputs.push((named(keyboard::key::Named::Escape), None));
|
||||
let (messages, _) = mount.batch(Some(&state), &inputs);
|
||||
let outcomes: Vec<_> = messages
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
state.update(event)
|
||||
})
|
||||
.collect();
|
||||
let mut expected = vec![Outcome::Browsed; query.chars().count()];
|
||||
expected.push(Outcome::Closed(Close::Escape));
|
||||
assert_eq!(outcomes, expected);
|
||||
assert_eq!(state.query(), "@controller_0720");
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000)));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::ArrowUp), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 3);
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011));
|
||||
assert_eq!(state.start(), 11);
|
||||
assert_eq!(state.update(old_absolute_row), Outcome::Ignored);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
mount.clipboard.0 = Some("@controller_0472".into());
|
||||
let paste = Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character("v".into()),
|
||||
modified_key: keyboard::Key::Character("v".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::COMMAND,
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::COMMAND,
|
||||
)),
|
||||
None,
|
||||
),
|
||||
(paste, None),
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::empty(),
|
||||
)),
|
||||
None,
|
||||
),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Paste should emit exactly one whole-string query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), "@controller_0472");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472));
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
assert!(state.is_open());
|
||||
|
||||
let unicode_name = "Режим Δ負荷";
|
||||
let unicode = Candidate {
|
||||
id: Id::from_u128(30_111),
|
||||
name: unicode_name.into(),
|
||||
alias: "mode_unicode".into(),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Система / 控制".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![unicode],
|
||||
Current::InvalidRaw("retained".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
use iced::advanced::input_method::Event as Ime;
|
||||
let preedit = [
|
||||
(Input::InputMethod(Ime::Opened), None),
|
||||
(
|
||||
Input::InputMethod(Ime::Preedit(unicode_name.into(), None)),
|
||||
None,
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
mount.batch(Some(&state), &preedit).0.is_empty(),
|
||||
"Composition is not a committed query"
|
||||
);
|
||||
assert_eq!(state.query(), "");
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(Input::InputMethod(Ime::Preedit(String::new(), None)), None),
|
||||
(Input::InputMethod(Ime::Commit(unicode_name.into())), None),
|
||||
(Input::InputMethod(Ime::Closed), None),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("IME commit should emit one complete query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), unicode_name);
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111));
|
||||
assert_eq!(state.current, Current::InvalidRaw("retained".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
//! Keyboard entry and focus operations scoped to one property form. Focus
|
||||
//! targets include the draft generation so delayed operations cannot focus a
|
||||
//! replacement array row or a recovered document with the same semantic IDs.
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Length, Rectangle, Size, Task, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, operation, tree},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::container,
|
||||
};
|
||||
use oa_core::Id as ModelId;
|
||||
mod focus;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FormScope {
|
||||
pub session: ModelId,
|
||||
pub modal: bool,
|
||||
}
|
||||
pub(super) struct FormOwner(pub Option<FormScope>);
|
||||
const REVIEW_MODAL: &str = "reference-owning-review-modal";
|
||||
|
||||
/// Marks the entire Review dialog without changing its layout or event flow.
|
||||
pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Field {
|
||||
content: content.into(),
|
||||
id: Some(Id::new(REVIEW_MODAL)),
|
||||
on_open: None,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn field_id(generation: ModelId, path: &str) -> Id {
|
||||
Id::from(format!("reference:{generation}:{path}"))
|
||||
}
|
||||
pub fn scope_id(session: ModelId) -> Id {
|
||||
Id::from(format!("reference-form:{session}"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
identity: Option<Id>,
|
||||
anchor: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Interaction {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
}
|
||||
pub struct Field<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
id: Option<Id>,
|
||||
on_open: Option<Box<dyn Fn(Rectangle) -> Message + 'a>>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Field<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
id: Id,
|
||||
on_open: impl Fn(Rectangle) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
id: Some(id),
|
||||
on_open: Some(Box::new(on_open)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn scope<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
session: ModelId,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
container(Field {
|
||||
content: content.into(),
|
||||
id: None,
|
||||
on_open: None,
|
||||
})
|
||||
.id(scope_id(session))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn run<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
mut operation: Box<dyn Operation>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
loop {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => {
|
||||
operation = next;
|
||||
changed = true;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn tab<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
scope: FormScope,
|
||||
backwards: bool,
|
||||
) -> bool {
|
||||
run(
|
||||
content,
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
Box::new(focus::relative(scope.session, backwards, scope.modal)),
|
||||
)
|
||||
}
|
||||
pub(super) fn own_input<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
) {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut focus::exclusive());
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.identity != self.id {
|
||||
*state = Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
if let Some(id) = &self.id
|
||||
&& self.on_open.is_some()
|
||||
{
|
||||
operation.focusable(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
operation.custom(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
}
|
||||
if self.on_open.is_none() {
|
||||
operation.container(self.id.as_ref(), layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if let Some(on_open) = &self.on_open {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds));
|
||||
let activate = match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if hovered => {
|
||||
state.focused = true;
|
||||
state.pressed = true;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
false
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let activate = state.pressed && hovered;
|
||||
if state.pressed {
|
||||
shell.capture_event();
|
||||
}
|
||||
state.pressed = false;
|
||||
activate
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key:
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space),
|
||||
repeat: false,
|
||||
..
|
||||
}) if state.focused => true,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
state.focused = false;
|
||||
state.pressed = false;
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if activate && let Some(bounds) = state.anchor {
|
||||
shell.publish(on_open(bounds));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.on_open.is_some() {
|
||||
let focus = tree.state.downcast_ref::<Interaction>().focused;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: if focus || cursor.is_over(layout.bounds()) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
},
|
||||
width: if focus { 2. } else { 1. },
|
||||
radius: 5.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
}
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.on_open.is_some() && cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable child updates use content coordinates. The root visits the
|
||||
/// retained tree before each input, so field anchors use current window
|
||||
/// coordinates even when a wheel event and activation share one runtime batch.
|
||||
pub(super) fn measure<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
viewport: Rectangle,
|
||||
) {
|
||||
content.as_widget_mut().operate(
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
&mut Measure {
|
||||
translation: Vector::ZERO,
|
||||
viewport: Some(viewport),
|
||||
pending: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
struct Measure {
|
||||
translation: Vector,
|
||||
viewport: Option<Rectangle>,
|
||||
pending: Option<(Vector, Option<Rectangle>)>,
|
||||
}
|
||||
impl Operation for Measure {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.viewport);
|
||||
if let Some((translation, viewport)) = self.pending.take() {
|
||||
self.translation = translation;
|
||||
self.viewport = viewport;
|
||||
}
|
||||
visit(self);
|
||||
(self.translation, self.viewport) = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
self.pending = Some((
|
||||
self.translation + translation,
|
||||
self.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds)),
|
||||
));
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, bounds: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(state) = state.downcast_mut::<Interaction>() {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
state.anchor = self
|
||||
.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Field<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(field: Field<'a, Message, R>) -> Self {
|
||||
Element::new(field)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_operation(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> impl Operation + 'static {
|
||||
focus::exact(session, generation, path, step)
|
||||
}
|
||||
pub fn focus<Message: Send + 'static>(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> Task<Message> {
|
||||
iced::advanced::widget::operate(focus_operation(session, generation, path, step)).discard()
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
//! Focus movement and reveal share one root traversal. A form may be inside a
|
||||
//! scrollable; restricting traversal to its container would hide that ancestor.
|
||||
use super::{FormOwner, Id, ModelId, Operation, Rectangle, Vector, field_id, operation, scope_id};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
enum Origin {
|
||||
Current,
|
||||
Exact(Id),
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Field {
|
||||
id: Option<Id>,
|
||||
bounds: Rectangle,
|
||||
ancestors: Vec<Scroll>,
|
||||
}
|
||||
pub(super) struct Collect {
|
||||
scope: Id,
|
||||
origin: Origin,
|
||||
step: i32,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending_scroll: Option<Scroll>,
|
||||
scroll_index: usize,
|
||||
fields: Vec<Field>,
|
||||
focused: Option<usize>,
|
||||
outside_focused: bool,
|
||||
modal: bool,
|
||||
owner_valid: bool,
|
||||
}
|
||||
impl Collect {
|
||||
fn new(scope: Id, origin: Origin, step: i32) -> Self {
|
||||
let owner_valid = matches!(origin, Origin::Current);
|
||||
Self {
|
||||
scope,
|
||||
origin,
|
||||
step,
|
||||
inside: false,
|
||||
entering: None,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending_scroll: None,
|
||||
scroll_index: 0,
|
||||
fields: vec![],
|
||||
focused: None,
|
||||
outside_focused: false,
|
||||
modal: false,
|
||||
owner_valid,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn relative(session: ModelId, backwards: bool, modal: bool) -> Collect {
|
||||
Collect {
|
||||
modal,
|
||||
..Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Current,
|
||||
if backwards { -1 } else { 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
pub(super) fn exact(session: ModelId, generation: ModelId, path: &str, step: i32) -> Collect {
|
||||
Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Exact(field_id(generation, path)),
|
||||
step,
|
||||
)
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.modal = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.inside, self.translation, self.scrolls.len());
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
if let Some(scroll) = self.pending_scroll.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous.0;
|
||||
self.translation = previous.1;
|
||||
self.scrolls.truncate(previous.2);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending_scroll = Some(Scroll {
|
||||
index: self.scroll_index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if !self.inside {
|
||||
self.outside_focused |= state.is_focused();
|
||||
return;
|
||||
}
|
||||
if state.is_focused() {
|
||||
self.focused = Some(self.fields.len());
|
||||
}
|
||||
self.fields.push(Field {
|
||||
id: id.cloned(),
|
||||
bounds: translated(bounds, self.translation),
|
||||
ancestors: self.scrolls.clone(),
|
||||
});
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.owner_valid || self.fields.is_empty() {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let origin = match &self.origin {
|
||||
Origin::Current if self.focused.is_none() && self.outside_focused && !self.modal => {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
Origin::Current => self
|
||||
.focused
|
||||
.map(|index| index as i64)
|
||||
.unwrap_or(if self.step < 0 {
|
||||
self.fields.len() as i64
|
||||
} else {
|
||||
-1
|
||||
}),
|
||||
Origin::Exact(id) => {
|
||||
let Some(index) = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.id.as_ref() == Some(id))
|
||||
else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
index as i64
|
||||
}
|
||||
};
|
||||
// A form keeps a reachable focus at both boundaries. Initial Tab enters
|
||||
// the first field; initial Shift+Tab enters the last field.
|
||||
let target = (origin + i64::from(self.step)).rem_euclid(self.fields.len() as i64) as usize;
|
||||
let field = &self.fields[target];
|
||||
let mut bounds = field.bounds;
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in field.ancestors.iter().rev() {
|
||||
let dx = reveal_axis(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal_axis(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
// Iced reports rounded drawing offsets, while capacity can be
|
||||
// fractional. Clamping an already-visible axis can move it a pixel.
|
||||
let delta = Vector::new(
|
||||
if dx == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
|
||||
},
|
||||
if dy == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
|
||||
},
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope.clone(),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts,
|
||||
owner_valid: matches!(self.origin, Origin::Current),
|
||||
clear_outside: self.modal,
|
||||
preserve_inside: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal_axis(start: f32, length: f32, viewport_start: f32, viewport_length: f32) -> f32 {
|
||||
if start < viewport_start || length > viewport_length {
|
||||
start - viewport_start
|
||||
} else {
|
||||
(start + length - viewport_start - viewport_length).max(0.)
|
||||
}
|
||||
}
|
||||
pub(super) struct Apply {
|
||||
scope: Id,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
target: usize,
|
||||
field_index: usize,
|
||||
scroll_index: usize,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
owner_valid: bool,
|
||||
clear_outside: bool,
|
||||
preserve_inside: bool,
|
||||
}
|
||||
pub(super) fn exclusive() -> Apply {
|
||||
Apply {
|
||||
scope: Id::new(super::REVIEW_MODAL),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target: 0,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts: BTreeMap::new(),
|
||||
owner_valid: true,
|
||||
clear_outside: true,
|
||||
preserve_inside: true,
|
||||
}
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.clear_outside = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = self.inside;
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.owner_valid
|
||||
&& let Some(delta) = self.shifts.get(&self.scroll_index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.owner_valid && self.inside && !self.preserve_inside {
|
||||
if self.field_index == self.target {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
self.field_index += 1;
|
||||
} else if self.owner_valid && !self.inside && self.clear_outside {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,994 +0,0 @@
|
||||
//! Root overlay adapter. Keep the wrapper mounted while closed so the release
|
||||
//! from a dismissal gesture cannot reach the underlying content. Parent modal
|
||||
//! ownership is supplied by the host (pass no chooser while a modal is active).
|
||||
use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser};
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
text::{self, Paragraph as _},
|
||||
widget::{Operation, Tree, tree},
|
||||
},
|
||||
alignment, keyboard, mouse, touch,
|
||||
widget::text_input,
|
||||
window,
|
||||
};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
const ROW: f32 = 34.;
|
||||
const MARGIN: f32 = 8.;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Geometry {
|
||||
pub popup: Rectangle,
|
||||
pub search: Rectangle,
|
||||
pub current: Rectangle,
|
||||
pub detail: Rectangle,
|
||||
pub close: Rectangle,
|
||||
pub previous: Rectangle,
|
||||
pub next: Rectangle,
|
||||
pub clear: Rectangle,
|
||||
pub rows: usize,
|
||||
}
|
||||
impl Geometry {
|
||||
fn new(anchor: Rectangle, viewport: Size) -> Self {
|
||||
let width = anchor
|
||||
.width
|
||||
.clamp(278., 420.)
|
||||
.min((viewport.width - 2. * MARGIN).max(0.));
|
||||
let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.));
|
||||
let x = anchor.x.clamp(
|
||||
MARGIN.min(viewport.width / 2.),
|
||||
(viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)),
|
||||
);
|
||||
let below = anchor.y + anchor.height + 4.;
|
||||
let preferred = if below + height + MARGIN <= viewport.height {
|
||||
below
|
||||
} else {
|
||||
anchor.y - height - 4.
|
||||
};
|
||||
let y = preferred.clamp(
|
||||
MARGIN.min(viewport.height / 2.),
|
||||
(viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)),
|
||||
);
|
||||
Self::from_bounds(Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub(super) fn from_bounds(popup: Rectangle) -> Self {
|
||||
let inner = (popup.width - 16.).max(0.);
|
||||
let rect = |left, top, width, height| Rectangle {
|
||||
x: popup.x + left,
|
||||
y: popup.y + top,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let footer = (popup.height - 32.).max(0.);
|
||||
let detail_height = if popup.height >= 180. { 32. } else { 0. };
|
||||
let detail_top = (footer - detail_height - 4.).max(84.);
|
||||
let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS);
|
||||
Self {
|
||||
popup,
|
||||
search: rect(8., 28., inner, 30.),
|
||||
current: rect(8., 62., inner, 18.),
|
||||
detail: rect(8., detail_top, inner, detail_height),
|
||||
close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.),
|
||||
previous: rect(8., footer, 28., 24.),
|
||||
next: rect(40., footer, 28., 24.),
|
||||
clear: rect((popup.width - 84.).max(0.), footer, 76., 24.),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
pub fn row(&self, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: self.popup.x + 8.,
|
||||
y: self.popup.y + 84. + index as f32 * ROW,
|
||||
width: (self.popup.width - 16.).max(0.),
|
||||
height: ROW,
|
||||
}
|
||||
}
|
||||
fn hit(&self, chooser: &Chooser, point: Point) -> Option<Action> {
|
||||
if self.close.contains(point) {
|
||||
return Some(Action::Close(Close::Escape));
|
||||
}
|
||||
if self.previous.contains(point) && chooser.start() > 0 {
|
||||
return Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.next.contains(point) && chooser.start() + self.rows < chooser.count() {
|
||||
return Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.clear.contains(point) && chooser.optional() {
|
||||
return Some(Action::Clear);
|
||||
}
|
||||
chooser
|
||||
.visible(self.rows)
|
||||
.enumerate()
|
||||
.find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
stamp: Option<Stamp>,
|
||||
serial: u64,
|
||||
pressed: Option<(ChoiceEvent, Pointer)>,
|
||||
closing: bool,
|
||||
swallow_mouse: Option<mouse::Button>,
|
||||
swallow_touch: Option<touch::Finger>,
|
||||
held_mouse: Option<mouse::Button>,
|
||||
held_touch: Option<touch::Finger>,
|
||||
detail_scroll: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Interaction {
|
||||
fn swallow(&mut self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.swallow_mouse == Some(*button) =>
|
||||
{
|
||||
self.swallow_mouse = None;
|
||||
true
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.swallow_touch == Some(*id) => {
|
||||
self.swallow_touch = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: text::Renderer<Font = Font>,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
input: Element<'a, String, Theme, R>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: Rc<dyn Fn(ChoiceEvent) -> Message + 'a>,
|
||||
focus_scope: Option<super::trigger::FormScope>,
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> Popup<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: impl Fn(ChoiceEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
let chooser = chooser.filter(|state| state.is_open());
|
||||
let input = text_input(
|
||||
"Name, @alias, kind or containment",
|
||||
chooser.map_or("", Chooser::query),
|
||||
)
|
||||
.on_input(std::convert::identity)
|
||||
.size(12)
|
||||
.padding(6)
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
Self {
|
||||
content: content.into(),
|
||||
input,
|
||||
chooser,
|
||||
anchor,
|
||||
on_event: Rc::new(on_event),
|
||||
focus_scope: None,
|
||||
}
|
||||
}
|
||||
pub fn focus_scope(mut self, scope: Option<super::trigger::FormScope>) -> Self {
|
||||
self.focus_scope = scope;
|
||||
self
|
||||
}
|
||||
fn reconcile(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let stamp = self.chooser.map(Chooser::stamp);
|
||||
if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) {
|
||||
let input = tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<R::Paragraph>>();
|
||||
if stamp.is_some() {
|
||||
input.focus();
|
||||
} else {
|
||||
input.unfocus();
|
||||
}
|
||||
state.closing = false;
|
||||
state.serial = self.chooser.map_or(0, Chooser::query_serial);
|
||||
}
|
||||
if state.stamp != stamp {
|
||||
state.pressed = None;
|
||||
// A queued Choose can be rejected after a preceding query changed
|
||||
// the result generation. Reconciliation must unlock this view.
|
||||
state.closing = false;
|
||||
state.detail_scroll = 0.;
|
||||
}
|
||||
state.stamp = stamp;
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> Widget<Message, Theme, R>
|
||||
for Popup<'_, Message, R>
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![
|
||||
Tree::new(self.content.as_widget()),
|
||||
Tree::new(self.input.as_widget()),
|
||||
]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.children[0].diff(self.content.as_widget());
|
||||
tree.children[1].diff(self.input.as_widget());
|
||||
self.reconcile(tree);
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.reconcile(tree);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
let mut owner =
|
||||
super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none()));
|
||||
operation.custom(None, layout.bounds(), &mut owner);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.swallow(event) || self.chooser.is_some() && is_input(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) {
|
||||
super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer);
|
||||
}
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Tab),
|
||||
modifiers,
|
||||
..
|
||||
}) = event
|
||||
&& let Some(scope) = self.focus_scope
|
||||
&& super::trigger::tab(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
scope,
|
||||
modifiers.shift(),
|
||||
)
|
||||
{
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
super::trigger::measure(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
*viewport,
|
||||
);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
if self.chooser.is_some() {
|
||||
mouse::Cursor::Unavailable
|
||||
} else {
|
||||
cursor
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.chooser.is_some() {
|
||||
mouse::Interaction::None
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
if let Some(chooser) = self.chooser {
|
||||
Some(overlay::Element::new(Box::new(ChooserOverlay {
|
||||
chooser,
|
||||
anchor: self.anchor,
|
||||
state: tree.state.downcast_mut(),
|
||||
input: &mut self.input,
|
||||
input_tree: &mut tree.children[1],
|
||||
on_event: &*self.on_event,
|
||||
})))
|
||||
} else {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> From<Popup<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(popup: Popup<'a, Message, R>) -> Self {
|
||||
Element::new(popup)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooserOverlay<'b, 'a, Message, R: text::Renderer<Font = Font>> {
|
||||
chooser: &'a Chooser,
|
||||
anchor: Rectangle,
|
||||
state: &'b mut Interaction,
|
||||
input: &'b mut Element<'a, String, Theme, R>,
|
||||
input_tree: &'b mut Tree,
|
||||
on_event: &'b dyn Fn(ChoiceEvent) -> Message,
|
||||
}
|
||||
impl<Message, R: text::Renderer<Font = Font>> ChooserOverlay<'_, '_, Message, R> {
|
||||
fn geometry(&self, layout: Layout<'_>) -> Geometry {
|
||||
Geometry::from_bounds(
|
||||
layout
|
||||
.children()
|
||||
.next()
|
||||
.expect("measured chooser bounds")
|
||||
.bounds(),
|
||||
)
|
||||
}
|
||||
fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) {
|
||||
if self.state.closing {
|
||||
return;
|
||||
}
|
||||
if action.writes_staged_value() || matches!(action, Action::Close(_)) {
|
||||
self.state.closing = true;
|
||||
self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse);
|
||||
self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch);
|
||||
}
|
||||
shell.publish((self.on_event)(self.chooser.event(action)));
|
||||
}
|
||||
fn press(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.publish(Action::Close(Close::Outside), shell);
|
||||
} else {
|
||||
self.state.pressed = geometry
|
||||
.hit(self.chooser, point)
|
||||
.map(|action| (self.chooser.event(action), pointer));
|
||||
}
|
||||
}
|
||||
fn release(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if let Some((pressed, origin)) = self.state.pressed.take()
|
||||
&& origin == pointer
|
||||
&& pressed.stamp == self.chooser.stamp()
|
||||
&& geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action)
|
||||
{
|
||||
self.publish(pressed.action, shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, Theme, R>
|
||||
for ChooserOverlay<'_, '_, Message, R>
|
||||
{
|
||||
fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node {
|
||||
let geometry = Geometry::new(self.anchor, bounds);
|
||||
let input = self
|
||||
.input
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
&layout::Limits::new(Size::ZERO, geometry.search.size()),
|
||||
)
|
||||
.move_to(geometry.search.position());
|
||||
layout::Node::with_children(
|
||||
bounds,
|
||||
vec![
|
||||
layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()),
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) {
|
||||
self.input.as_widget_mut().operate(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
self.state.held_mouse = Some(*button)
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.state.held_mouse == Some(*button) =>
|
||||
{
|
||||
self.state.held_mouse = None
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
|
||||
self.state.held_touch = Some(*id)
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.state.held_touch == Some(*id) => self.state.held_touch = None,
|
||||
_ => {}
|
||||
}
|
||||
if self.state.swallow(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.state.closing {
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut handled = false;
|
||||
match event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
|
||||
use keyboard::key::Named;
|
||||
let action = match key {
|
||||
keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)),
|
||||
keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab {
|
||||
backwards: modifiers.shift(),
|
||||
})),
|
||||
keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageDown) => Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageUp) => Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::Enter) => {
|
||||
handled = true;
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id))
|
||||
.map(|c| Action::Choose(c.id))
|
||||
}
|
||||
keyboard::Key::Character(c)
|
||||
if modifiers.control()
|
||||
&& modifiers.shift()
|
||||
&& c.eq_ignore_ascii_case("c") =>
|
||||
{
|
||||
clipboard.write(
|
||||
iced::advanced::clipboard::Kind::Standard,
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail()),
|
||||
);
|
||||
handled = true;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
self.publish(action, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => {
|
||||
self.publish(Action::Close(Close::WindowChanged), shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if let Some(point) = cursor.position() {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.state.swallow_mouse = Some(*button);
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
} else if *button == mouse::Button::Left && !geometry.search.contains(point) {
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
if let Some(point) = cursor.position() {
|
||||
self.release(point, Pointer::Mouse, geometry, shell);
|
||||
} else {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position })
|
||||
if self.state.pressed.is_none() =>
|
||||
{
|
||||
if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position)
|
||||
&& self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_none_or(|candidate| candidate.id != id)
|
||||
{
|
||||
self.publish(Action::Highlight(id), shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
|
||||
let (x, y) = match delta {
|
||||
mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.),
|
||||
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
|
||||
};
|
||||
self.state.pressed = None;
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.detail.contains(p))
|
||||
{
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
let max = (measure::<R>(&detail, 11., renderer.default_font())
|
||||
- geometry.detail.width)
|
||||
.max(0.);
|
||||
self.state.detail_scroll =
|
||||
(self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max);
|
||||
shell.request_redraw();
|
||||
} else if y != 0. {
|
||||
self.publish(
|
||||
Action::Page {
|
||||
delta: if y < 0. { 1 } else { -1 },
|
||||
rows: geometry.rows,
|
||||
},
|
||||
shell,
|
||||
);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, position })
|
||||
if !geometry.search.contains(*position) =>
|
||||
{
|
||||
if !geometry.popup.contains(*position) {
|
||||
self.state.swallow_touch = Some(*id);
|
||||
}
|
||||
self.press(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLifted { id, position })
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
self.release(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !handled {
|
||||
let mut messages = Vec::new();
|
||||
let mut input_shell = Shell::new(&mut messages);
|
||||
self.input.as_widget_mut().update(
|
||||
self.input_tree,
|
||||
event,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut input_shell,
|
||||
&geometry.popup,
|
||||
);
|
||||
let serial = Cell::new(self.state.serial);
|
||||
shell.merge(input_shell, |text| {
|
||||
serial.set(serial.get().saturating_add(1));
|
||||
(self.on_event)(self.chooser.event(Action::Query {
|
||||
text,
|
||||
serial: serial.get(),
|
||||
}))
|
||||
});
|
||||
self.state.serial = serial.get();
|
||||
}
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: geometry.popup,
|
||||
border: Border {
|
||||
color: style::LINE,
|
||||
width: 1.,
|
||||
radius: 8.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
renderer.with_layer(geometry.popup, |renderer| {
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.scope_label,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.popup.x + 8.,
|
||||
y: geometry.popup.y + 4.,
|
||||
width: (geometry.popup.width - 78.).max(0.),
|
||||
height: 22.,
|
||||
},
|
||||
);
|
||||
label(renderer, "Close", 11., style::TEAL, geometry.close);
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.current.label(),
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.current,
|
||||
);
|
||||
for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() {
|
||||
let row = geometry.row(i);
|
||||
if self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_some_and(|c| c.id == candidate.id)
|
||||
{
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: row,
|
||||
..Default::default()
|
||||
},
|
||||
Color::from_rgb8(218, 238, 235),
|
||||
);
|
||||
}
|
||||
let alias_width = (row.width * 0.42).min(
|
||||
measure::<R>(
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
renderer.default_font(),
|
||||
) + 4.,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.name,
|
||||
12.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
width: (row.width - alias_width - 6.).max(0.),
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
x: row.x + row.width - alias_width,
|
||||
width: alias_width,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.context_label(),
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: row.y + 17.,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.chooser.count() == 0 {
|
||||
label(
|
||||
renderer,
|
||||
"No compatible matches. Clear the query.",
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.row(0),
|
||||
);
|
||||
}
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
renderer.with_layer(geometry.detail, |renderer| {
|
||||
raw_label(
|
||||
renderer,
|
||||
detail,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.detail.x - self.state.detail_scroll,
|
||||
width: geometry.detail.width + self.state.detail_scroll,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"Scroll details · Ctrl+Shift+C copies",
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: geometry.detail.y + 16.,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
});
|
||||
label(
|
||||
renderer,
|
||||
"‹",
|
||||
15.,
|
||||
if self.chooser.start() > 0 {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.previous,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"›",
|
||||
15.,
|
||||
if self.chooser.start() + geometry.rows < self.chooser.count() {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.next,
|
||||
);
|
||||
let count = if self.chooser.count() == 0 {
|
||||
"0 results".into()
|
||||
} else {
|
||||
format!(
|
||||
"{}–{} / {}",
|
||||
self.chooser.start() + 1,
|
||||
(self.chooser.start() + geometry.rows).min(self.chooser.count()),
|
||||
self.chooser.count()
|
||||
)
|
||||
};
|
||||
label(
|
||||
renderer,
|
||||
&count,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.next.x + 30.,
|
||||
width: (geometry.clear.x - geometry.next.x - 32.).max(0.),
|
||||
..geometry.next
|
||||
},
|
||||
);
|
||||
if self.chooser.optional() {
|
||||
label(
|
||||
renderer,
|
||||
"Clear reference",
|
||||
10.,
|
||||
style::TEAL,
|
||||
geometry.clear,
|
||||
);
|
||||
}
|
||||
self.input.as_widget().draw(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
let geometry = self.geometry(layout);
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.hit(self.chooser, p).is_some())
|
||||
{
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_input(event: &Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
|
||||
)
|
||||
}
|
||||
fn measure<R: text::Renderer<Font = Font>>(content: &str, size: f32, font: Font) -> f32 {
|
||||
R::Paragraph::with_text(text::Text {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font,
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
})
|
||||
.min_width()
|
||||
}
|
||||
fn label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: &str,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let font = renderer.default_font();
|
||||
let mut display = content.to_owned();
|
||||
if measure::<R>(content, size, font) > bounds.width {
|
||||
let ends: Vec<_> = content
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(content.len()))
|
||||
.collect();
|
||||
let (mut low, mut high) = (0, ends.len());
|
||||
while low + 1 < high {
|
||||
let middle = (low + high) / 2;
|
||||
if measure::<R>(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width
|
||||
{
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
display = format!("{}…", &content[..ends[low]]);
|
||||
}
|
||||
raw_label(renderer, display, size, color, bounds);
|
||||
}
|
||||
fn raw_label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: String,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content,
|
||||
bounds: bounds.size(),
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
},
|
||||
Point::new(bounds.x, bounds.center_y()),
|
||||
color,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
@@ -445,32 +445,28 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"On an empty path, choose Work (W) from the right palette and click the canvas. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
|
||||
"Choose Work (W) or Exchange (E) from the permanent palette on the right, then a highlighted position. Hover a palette button to read its tool name. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"Choose Sequence link (->) and click or drag from the preceding contribution to the following contribution. Choose Select contributions or paths (S) to drag a selected path endpoint and reconnect it. A contribution's ↕ handle reorders contributions within the same column; links determine the columns. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Details → Two paths... creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions.",
|
||||
"Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Navigate the canvas",
|
||||
"The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.",
|
||||
),
|
||||
explain(
|
||||
"Choose the right behavioral description",
|
||||
"A chain concentrates on a route through existing exchanges. A scenario can also carry actions, state observations, lifelines and nested fragments. The architecture diagram remains a view of the possible relationships rather than an execution trace.",
|
||||
),
|
||||
explain(
|
||||
"Review the path as an engineer",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse process / chain (ref) in the palette to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Details → Append exchange... continues that path; Edit path links and guards exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Tools → Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
),
|
||||
walkthrough(
|
||||
"Connect a particular use of a procedure",
|
||||
&[
|
||||
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Details to read its complete caller and reference identity.",
|
||||
"In Details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
|
||||
"Open Details → Qualified link / assembly... and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
|
||||
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Selection details to read its complete caller and reference identity.",
|
||||
"In Selection details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
|
||||
"Open Tools → Qualified link / assembly… and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
|
||||
"Choose Precedence to describe order and its guard, Exchange assembly to identify a work exchange between the contributions, or Common work incidence to state that both use the same work. Select the engineering identity when required, then Apply link.",
|
||||
],
|
||||
),
|
||||
@@ -488,40 +484,28 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use a scenario to tell a concrete behavioral story, including repeated messages, actions, state observations and guarded alternatives.",
|
||||
keywords: "scenario sequence lifeline lifetime lifecycle birth death capsule sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
|
||||
keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Create a scenario",
|
||||
&[
|
||||
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
|
||||
"Choose Lifeline (L) in the permanent palette on the right, then click the canvas. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
|
||||
"Choose a message style from the palette; hover a button to read its tool name. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
|
||||
"Call and correlated reply (C/R) creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose Action (A), State observation (St), Reuse scenario (ref) or Fragment (alt) and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
|
||||
"Choose Select / reorder occurrences (S) and drag an occurrence itself to another position within the same sequence. A drag into another fragment operand is refused. Open Details for the ordered list's up/down controls or Properties for the selected occurrence, including its message values.",
|
||||
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
|
||||
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
|
||||
"Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
|
||||
"In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Navigate the canvas",
|
||||
"The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.",
|
||||
),
|
||||
walkthrough(
|
||||
"Describe alternatives",
|
||||
&[
|
||||
"Choose Fragment (alt), click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
|
||||
"Click the highlighted position inside an operand to place the next message, action or reference there. The form shows its destination and insertion position. Append to this sequence instead explicitly moves the pending insertion to that sequence's end.",
|
||||
"Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Details → Open definition. Back returns to that exact use and its viewport after you apply or cancel pending edits.",
|
||||
"Choose Fragment and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
|
||||
"Use the Destination selector in subsequent message or action forms to choose the intended operand. New occurrences append to that operand's ordered behavior.",
|
||||
"Use Reuse scenario to refer to existing behavior. Bind every referenced lifeline to a compatible local lifeline explicitly. Open source follows the original scenario without copying its contents.",
|
||||
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Read lifeline creation and deletion",
|
||||
"The Lane header and dotted guide identify a lifeline; the guide alone does not establish its lifetime. The stronger solid line shows where the lifeline is known to exist on this scenario path. Create starts that line at a named capsule; Delete ends it at an X. Select the capsule or X to edit that message occurrence. Select the header or guide to inspect the lifeline.",
|
||||
),
|
||||
explain(
|
||||
"Distinguish uncertain lifetime from an unanswered call",
|
||||
"A ? means lifetime depends on an alternative, optional, repeated or parallel path. Operands have independent lifetimes: Delete in one alternative does not delete a lifeline in another. Beyond such a fragment, the guide can continue without a definite solid line. This uncertainty is not by itself a model error. The … beside an activation marks a Call that remains unanswered on that path. A reused scenario's internal Create/Delete events appear when you choose Open definition; they do not create or delete the caller's lifelines.",
|
||||
),
|
||||
walkthrough(
|
||||
"Invoke an interface operation",
|
||||
&[
|
||||
@@ -559,8 +543,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Build a state machine",
|
||||
&[
|
||||
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.",
|
||||
"If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.",
|
||||
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.",
|
||||
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
@@ -714,10 +697,6 @@ fn article(topic: Topic) -> Article {
|
||||
"Map references by meaning",
|
||||
"Reference mapping connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
|
||||
),
|
||||
explain(
|
||||
"Edit stored observations deliberately",
|
||||
"In a trace's Properties, Edit stored history (advanced)… edits the observations retained by that trace. Choose the fact and enter its Operational and System observations explicitly. Not applicable, an observed absent value, and no stored fact have different meanings. Historical references may retain the exact UUID of a deleted record. Apply changes validates the complete history; Undo restores it. These history edits leave both counterpart records unchanged. Use Review counterpart changes… for Keep/Take decisions about their current engineering content.",
|
||||
),
|
||||
walkthrough(
|
||||
"Review one message value or lifeline binding",
|
||||
&[
|
||||
@@ -918,7 +897,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"A new behavior is still a draft",
|
||||
"Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
|
||||
"Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
|
||||
),
|
||||
explain(
|
||||
"Aliases and references",
|
||||
|
||||
@@ -87,9 +87,6 @@ pub enum RowEvent {
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
ValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
ReferenceBrowse(String, String, usize),
|
||||
ReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
ReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
ReferenceSet(crate::forms::reference_chooser::adapter::Set),
|
||||
OperationOmission(bool),
|
||||
OperationRationale(String),
|
||||
Tab(Tab),
|
||||
|
||||
@@ -296,51 +296,6 @@ impl State {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn reference_form(&self, project: &Project) -> Option<&Draft> {
|
||||
let facet = self.active.selected.as_ref()?;
|
||||
if self.frozen(project).is_err()
|
||||
|| self.active.tab != Tab::Facts
|
||||
|| self.active.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.active.candidate_draft(facet)
|
||||
}
|
||||
pub(crate) fn reference_draft(
|
||||
&self,
|
||||
project: &Project,
|
||||
address: Option<RowAddress>,
|
||||
facet: &FacetSelector,
|
||||
path: &str,
|
||||
) -> Option<&Draft> {
|
||||
if self.frozen(project).is_err()
|
||||
|| self.active_address != address
|
||||
|| self.active.tab != Tab::Facts
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.active.reference_draft(project, facet, path)
|
||||
}
|
||||
pub(crate) fn set_reference(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
address: Option<RowAddress>,
|
||||
facet: &FacetSelector,
|
||||
intent: crate::forms::reference_chooser::adapter::Set,
|
||||
) -> Result<(), String> {
|
||||
self.reference_draft(project, address, facet, &intent.context.path)
|
||||
.ok_or("The originating review row or property is no longer active.")?
|
||||
.reference_value(project, &intent)?;
|
||||
self.update(
|
||||
project,
|
||||
Event::Row {
|
||||
address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceSet(intent),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(&mut self, project: &Project, event: impl Into<Event>) {
|
||||
let event = event.into();
|
||||
#[cfg(test)]
|
||||
@@ -348,30 +303,6 @@ impl State {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
match &event {
|
||||
Event::Row {
|
||||
event: RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_),
|
||||
..
|
||||
}
|
||||
| Event::Editor(RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_)) => return,
|
||||
Event::Row {
|
||||
address,
|
||||
facet,
|
||||
event: RowEvent::ReferenceSet(intent),
|
||||
} => {
|
||||
let valid = facet
|
||||
.as_ref()
|
||||
.and_then(|facet| {
|
||||
self.reference_draft(project, *address, facet, &intent.context.path)
|
||||
})
|
||||
.is_some_and(|draft| draft.reference_value(project, intent).is_ok());
|
||||
if !valid {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Event::Editor(RowEvent::ReferenceSet(_)) => return,
|
||||
_ => {}
|
||||
}
|
||||
if let Err(error) = self.frozen(project) {
|
||||
// Withdrawing a pending reset is authored intent only. Retain it while
|
||||
// keeping the original accepted base and disabling every stale plan.
|
||||
@@ -533,8 +464,7 @@ impl State {
|
||||
);
|
||||
let scoped_edit = matches!(
|
||||
event,
|
||||
RowEvent::ReferenceSet(_)
|
||||
| RowEvent::Input(_, _)
|
||||
RowEvent::Input(_, _)
|
||||
| RowEvent::Text(_, _)
|
||||
| RowEvent::Shape(_, _)
|
||||
| RowEvent::Variant(_, _)
|
||||
@@ -694,7 +624,7 @@ impl State {
|
||||
Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text},
|
||||
};
|
||||
let message = |event| Message::Workflow(crate::workflow::Event::semantic(event));
|
||||
let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event));
|
||||
let mut available: Vec<_> = self
|
||||
.base
|
||||
.traces
|
||||
@@ -817,19 +747,13 @@ impl State {
|
||||
if let Some(notice) = &self.notice {
|
||||
header = header.push(text(notice).size(11));
|
||||
}
|
||||
let view = self
|
||||
.active
|
||||
self.active
|
||||
.view(&self.base, header.into(), self.can_apply())
|
||||
.map(move |message| match message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event)) => {
|
||||
let event = match *event {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::semantic(event))
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
crate::forms::reference_chooser::trigger::modal(view)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,10 +522,10 @@ fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() {
|
||||
&mut app,
|
||||
RowEvent::Input(path(f.direction, "minimum"), "120".into()),
|
||||
);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
|
||||
)));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::OpenTrace,
|
||||
)));
|
||||
tests::decide(
|
||||
|
||||
@@ -166,14 +166,6 @@ impl ReviewRow {
|
||||
pub fn update(&mut self, project: &Project, event: RowEvent) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
// Browser messages belong to the app overlay and must never replan.
|
||||
RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_) => return,
|
||||
RowEvent::ReferenceSet(intent) => {
|
||||
let path = intent.context.path.clone();
|
||||
self.edit(project, &path, |draft| {
|
||||
draft.reference_set(project, &intent)
|
||||
})
|
||||
}
|
||||
RowEvent::OperationOmission(enabled) => {
|
||||
self.operation_omission =
|
||||
enabled.then(|| self.operation_omission.clone().unwrap_or_default());
|
||||
@@ -526,25 +518,6 @@ impl ReviewRow {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reference_draft(
|
||||
&self,
|
||||
project: &Project,
|
||||
facet: &FacetSelector,
|
||||
path: &str,
|
||||
) -> Option<&Draft> {
|
||||
if self.selected.as_ref() != Some(facet)
|
||||
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
|| matches!(path, "/id" | "/alias" | "/body" | "/layer")
|
||||
|| !self
|
||||
.candidate_roots(project, facet)?
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.candidate_draft(facet)
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
@@ -922,9 +895,7 @@ pub fn form_event(message: Message) -> Message {
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
RowEvent::ReferenceBrowse(path, query, page)
|
||||
}
|
||||
Message::FieldReferenceOpen(request) => RowEvent::ReferenceOpen(request),
|
||||
Message::FieldReferenceEvent(event) => RowEvent::ReferenceEvent(event),
|
||||
_ => RowEvent::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event)))
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{app::App, document::Document, workflow::Workflow};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn send(app: &mut App, event: RowEvent) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
state(app).address_event(event),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use iced::{
|
||||
mod reserved_artifact_tests;
|
||||
|
||||
fn event(event: RowEvent) -> Message {
|
||||
Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event)))
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
}
|
||||
fn action<'a>(
|
||||
label: impl Into<String>,
|
||||
@@ -867,13 +867,7 @@ impl ReviewRow {
|
||||
) {
|
||||
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
|
||||
}
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
||||
{
|
||||
crate::forms::reference_chooser::trigger::scope(detail, draft.reference_session())
|
||||
} else {
|
||||
detail.into()
|
||||
}
|
||||
detail.into()
|
||||
}
|
||||
|
||||
fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
|
||||
@@ -1663,388 +1663,3 @@ fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_work_preserves_allocated_context_and_atomic_history() {
|
||||
use oa_core::projection::project_view;
|
||||
for (layer, performer_kind, work_kind) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::Function),
|
||||
] {
|
||||
for folded in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(performer_kind, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(work_kind, layer, "Parent work", None)
|
||||
.unwrap();
|
||||
let allocation = p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Engineering assignment",
|
||||
performer,
|
||||
work,
|
||||
);
|
||||
p.relations.get_mut(&allocation).unwrap().description =
|
||||
"Retain assignment metadata".into();
|
||||
let original = p.relations[&allocation].clone();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let performer_node = p.place(diagram, performer, Position { x: 60., y: 60. });
|
||||
let work_node = p.place(diagram, work, Position { x: 84., y: 124. });
|
||||
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
for node in [performer_node, work_node] {
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.collapsed = folded;
|
||||
}
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Layer(layer));
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
let mut first = None;
|
||||
for step in 0..3 {
|
||||
let parent = if step == 2 { first.unwrap() } else { work };
|
||||
send(&mut app, Message::Select(parent));
|
||||
let before = app.project().clone();
|
||||
let before_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::CreateContextElement(work_kind, true));
|
||||
let created = app.selected;
|
||||
assert!(
|
||||
!before.elements.contains_key(&created),
|
||||
"{layer:?} step {step}: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.project().elements.len(), before.elements.len() + 1);
|
||||
assert_eq!(app.project().elements[&created].parent, Some(parent));
|
||||
for (id, element) in &before.elements {
|
||||
assert_eq!(&app.project().elements[id], element);
|
||||
}
|
||||
assert!(
|
||||
!oa_core::validation::validate(app.project())
|
||||
.iter()
|
||||
.any(|i| i.severity == oa_core::validation::Severity::Error)
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::allocation_summary(app.project(), work),
|
||||
Some(performer)
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::effective_performer(app.project(), created),
|
||||
Some(performer)
|
||||
);
|
||||
assert!(
|
||||
!app.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
|
||||
);
|
||||
if step != 1 {
|
||||
let mut expected = original.clone();
|
||||
if let RelationBody::Allocation { target, .. } = &mut expected.body {
|
||||
*target = created;
|
||||
}
|
||||
assert_eq!(app.project().relations[&allocation], expected);
|
||||
} else {
|
||||
assert_eq!(
|
||||
app.project().relations[&allocation],
|
||||
before.relations[&allocation]
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.project().relations.len(),
|
||||
before.relations.len() + usize::from(step == 1)
|
||||
);
|
||||
let view = project_view(app.project(), diagram);
|
||||
let parent_node = view.nodes.iter().find(|n| n.element == parent).unwrap();
|
||||
let child_node = view.nodes.iter().find(|n| n.element == created).unwrap();
|
||||
let root_node = view.nodes.iter().find(|n| n.element == work).unwrap();
|
||||
assert_eq!(root_node.visual_parent, Some(performer_node));
|
||||
assert_eq!(child_node.visual_parent, Some(parent_node.id));
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&performer_node].collapsed);
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&parent_node.id].collapsed);
|
||||
assert!(child_node.position.x >= parent_node.position.x);
|
||||
assert!(child_node.position.y >= parent_node.position.y + 64.);
|
||||
assert!(
|
||||
child_node.position.x + child_node.size.width
|
||||
<= parent_node.position.x + parent_node.size.width
|
||||
);
|
||||
assert!(
|
||||
child_node.position.y + child_node.size.height
|
||||
<= parent_node.position.y + parent_node.size.height
|
||||
);
|
||||
for sibling in view
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.visual_parent == Some(parent_node.id) && n.id != child_node.id)
|
||||
{
|
||||
assert!(sibling.position.y + sibling.size.height <= child_node.position.y);
|
||||
}
|
||||
let after = app.project().clone();
|
||||
let after_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&before,
|
||||
"creation, allocation and reveal/reflow undo together"
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before_source);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after);
|
||||
assert_eq!(app.document.bundle(), &after_source);
|
||||
if step == 0 {
|
||||
first = Some(created);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_work_does_not_invent_unallocated_or_mixed_performers() {
|
||||
for (layer, performer_kind, other_kind, work_kind) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::SystemActor, Kind::Function),
|
||||
] {
|
||||
// Root leaf, partly unallocated composite, and mixed-performer composite.
|
||||
for context in 0..3 {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(performer_kind, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(other_kind, layer, "Other performer", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(work_kind, layer, "Parent work", None)
|
||||
.unwrap();
|
||||
let mut placed = vec![performer, other, work];
|
||||
if context != 0 {
|
||||
let first = p
|
||||
.add_element(work_kind, layer, "First leaf", Some(work))
|
||||
.unwrap();
|
||||
let second = p
|
||||
.add_element(work_kind, layer, "Second leaf", Some(work))
|
||||
.unwrap();
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"First assignment",
|
||||
performer,
|
||||
first,
|
||||
);
|
||||
if context == 2 {
|
||||
p.relate(RelationKind::Allocation, "Other assignment", other, second);
|
||||
}
|
||||
placed.extend([first, second]);
|
||||
}
|
||||
assert_eq!(oa_core::validation::allocation_summary(&p, work), None);
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
for id in placed {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Layer(layer));
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Message::Select(work));
|
||||
let before = app.project().clone();
|
||||
let before_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::CreateContextElement(work_kind, true));
|
||||
let child = app.selected;
|
||||
assert!(!before.elements.contains_key(&child), "{}", app.status);
|
||||
assert_eq!(app.project().elements[&child].parent, Some(work));
|
||||
assert_eq!(app.project().relations, before.relations);
|
||||
assert_eq!(
|
||||
oa_core::validation::effective_performer(app.project(), child),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::allocation_summary(app.project(), work),
|
||||
None
|
||||
);
|
||||
let after = app.project().clone();
|
||||
let after_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before);
|
||||
assert_eq!(app.document.bundle(), &before_source);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after);
|
||||
assert_eq!(app.document.bundle(), &after_source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_work_transfers_leaf_roles_without_fabricating_responsibilities() {
|
||||
// Direct allocation + matching roles, uniquely realized roles only,
|
||||
// unresolved roles, and roles realized by different performers.
|
||||
for context in 0..4 {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Other performer", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Responsible work", None)
|
||||
.unwrap();
|
||||
let mut responsibilities = Vec::new();
|
||||
if context == 0 {
|
||||
responsibilities.push(p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Direct responsibility",
|
||||
performer,
|
||||
work,
|
||||
));
|
||||
}
|
||||
for index in 0..2 {
|
||||
let role = p
|
||||
.add_element(Kind::Role, Layer::Oa, &format!("Role {index}"), None)
|
||||
.unwrap();
|
||||
responsibilities.push(p.relate(
|
||||
RelationKind::RoleAssignment,
|
||||
&format!("Assigned role {index}"),
|
||||
role,
|
||||
work,
|
||||
));
|
||||
if context != 2 {
|
||||
let source = if context == 3 && index == 1 {
|
||||
other
|
||||
} else {
|
||||
performer
|
||||
};
|
||||
p.relate(RelationKind::RoleRealization, "Realizes role", source, role);
|
||||
}
|
||||
}
|
||||
for (index, id) in responsibilities.iter().enumerate() {
|
||||
let relation = p.relations.get_mut(id).unwrap();
|
||||
relation.description =
|
||||
format!("Responsibility {index}: preserve engineering rationale");
|
||||
let link = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
link,
|
||||
ArtifactLink {
|
||||
id: link,
|
||||
alias: format!("responsibility_evidence_{index}"),
|
||||
name: format!("Responsibility evidence {index}"),
|
||||
description: "Preserve the reference to this exact relation".into(),
|
||||
owner: *id,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: format!("https://example.invalid/responsibility/{index}"),
|
||||
revision: Some("review-7".into()),
|
||||
fragment: Some("assignment".into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
let original_relations = p.relations.clone();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
p.place(diagram, performer, Position { x: 60., y: 60. });
|
||||
p.place(diagram, work, Position { x: 84., y: 124. });
|
||||
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let expected_performer = (context < 2).then_some(performer);
|
||||
assert_eq!(
|
||||
oa_core::validation::allocation_summary(&p, work),
|
||||
expected_performer
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
let mut first = None;
|
||||
for step in 0..2 {
|
||||
send(&mut app, Message::Select(work));
|
||||
let before = app.project().clone();
|
||||
let before_source = app.document.bundle().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Message::CreateContextElement(Kind::Activity, true),
|
||||
);
|
||||
let child = app.selected;
|
||||
assert!(
|
||||
!before.elements.contains_key(&child),
|
||||
"context {context}, step {step}: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.project().elements[&child].parent, Some(work));
|
||||
if step == 0 {
|
||||
first = Some(child);
|
||||
}
|
||||
for (id, original) in &original_relations {
|
||||
let mut expected = original.clone();
|
||||
if responsibilities.contains(id) {
|
||||
match &mut expected.body {
|
||||
RelationBody::Allocation { target, .. }
|
||||
| RelationBody::RoleAssignment { target, .. } => *target = first.unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert_eq!(app.project().relations[id], expected);
|
||||
}
|
||||
assert_eq!(app.project().artifact_links, before.artifact_links);
|
||||
assert!(!app.project().artifact_links.is_empty());
|
||||
let added = usize::from(step == 1 && expected_performer.is_some());
|
||||
assert_eq!(
|
||||
app.project().relations.len(),
|
||||
before.relations.len() + added
|
||||
);
|
||||
assert_eq!(
|
||||
oa_core::validation::effective_performer(app.project(), child),
|
||||
expected_performer
|
||||
);
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == child)
|
||||
.count(),
|
||||
if step == 0 { 2 } else { 0 }
|
||||
);
|
||||
if context != 0 && step == 0 {
|
||||
assert!(
|
||||
!app.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!oa_core::validation::validate(app.project())
|
||||
.iter()
|
||||
.any(|i| i.severity == oa_core::validation::Severity::Error)
|
||||
);
|
||||
let after = app.project().clone();
|
||||
let after_source = app.document.bundle().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before);
|
||||
assert_eq!(app.document.bundle(), &before_source);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after);
|
||||
assert_eq!(app.document.bundle(), &after_source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use iced::{
|
||||
},
|
||||
};
|
||||
use oa_core::*;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub fn label<'a>(s: impl Into<String>) -> widget::Text<'a> {
|
||||
text(s.into()).size(11).color(style::MUTED)
|
||||
@@ -25,12 +24,10 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
|
||||
}
|
||||
pub fn short(s: &str, limit: usize) -> String {
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if s.graphemes(true).count() > limit {
|
||||
if s.chars().count() > limit {
|
||||
format!(
|
||||
"{}…",
|
||||
s.graphemes(true)
|
||||
.take(limit.saturating_sub(1))
|
||||
.collect::<String>()
|
||||
s.chars().take(limit.saturating_sub(1)).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
s
|
||||
@@ -1293,57 +1290,3 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::short;
|
||||
|
||||
#[test]
|
||||
fn short_preserves_native_lifeline_clusters_at_cutoff() {
|
||||
let oa = "abcdefghijklmnope\u{0301}尾";
|
||||
assert_eq!(short(oa, 17), "abcdefghijklmnop…");
|
||||
assert_eq!(short(oa, 18), oa);
|
||||
assert_eq!(short(oa, 19), oa);
|
||||
|
||||
let sa = "abcdefghijklmnop👩\u{200d}🔬 tail";
|
||||
assert_eq!(short(sa, 17), "abcdefghijklmnop…");
|
||||
assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…");
|
||||
assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …");
|
||||
assert_eq!(short(sa, 22), sa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_never_splits_retained_extended_graphemes() {
|
||||
for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] {
|
||||
let value = format!("A{cluster}BC");
|
||||
assert_eq!(short(&value, 2), "A…");
|
||||
assert_eq!(short(&value, 3), format!("A{cluster}…"));
|
||||
assert_eq!(short(&value, 4), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_preserves_ascii_cjk_and_whitespace_policy() {
|
||||
assert_eq!(short("abcd", 3), "ab…");
|
||||
assert_eq!(short("abcd", 4), "abcd");
|
||||
assert_eq!(short("abcd", 5), "abcd");
|
||||
assert_eq!(short("甲乙丙丁", 3), "甲乙…");
|
||||
assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁");
|
||||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B");
|
||||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_preserves_empty_and_small_limit_policy() {
|
||||
for limit in [0, 1, 2] {
|
||||
assert_eq!(short("", limit), "");
|
||||
assert_eq!(short(" \t\r\n", limit), "");
|
||||
}
|
||||
assert_eq!(short("A", 0), "…");
|
||||
assert_eq!(short("A", 1), "A");
|
||||
assert_eq!(short("AB", 1), "…");
|
||||
assert_eq!(short("e\u{0301}", 0), "…");
|
||||
assert_eq!(short("e\u{0301}", 1), "e\u{0301}");
|
||||
assert_eq!(short("e\u{0301}B", 1), "…");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ mod transition_tests;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Semantic(Box<crate::semantic_review::Event>),
|
||||
Semantic(crate::semantic_review::Event),
|
||||
Port(crate::port_workflow::Event),
|
||||
ExchangeItem(Id, bool),
|
||||
Endpoint(bool, EndpointChoice),
|
||||
@@ -41,11 +41,6 @@ pub enum Event {
|
||||
Resolve(Id, TransitionField, FieldDecision),
|
||||
Preview,
|
||||
}
|
||||
impl Event {
|
||||
pub fn semantic(event: crate::semantic_review::Event) -> Self {
|
||||
Self::Semantic(Box::new(event))
|
||||
}
|
||||
}
|
||||
pub enum Workflow {
|
||||
Semantic(Box<crate::semantic_review::State>),
|
||||
Ports(Box<crate::port_workflow::State>),
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
Run from any directory with Python 3: semantic_schema.py [--check] [--validate].
|
||||
--validate additionally runs source-7 structural fixtures with jsonschema.
|
||||
The desktop stored-history editor reads the checked-in descriptor for field
|
||||
shapes. Core types and validation remain authoritative for model mutations.
|
||||
This generates documentation only, never runtime selectors or model mutations.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
|
||||
+5
-49
@@ -1319,25 +1319,16 @@ references a Call occurrence in the same scenario. The call MUST precede the
|
||||
reply on a guaranteed common sequence/enclosing branch path. Reply lifelines
|
||||
reverse the call's sender/receiver; it may reuse the call's exchange in reverse
|
||||
or use an explicit reverse-direction exchange. A second reply to the same call
|
||||
that can follow another reply on a shared execution path, including conditional
|
||||
or parallel operands, is an error. Missing reply correlation is a draft
|
||||
on that guaranteed path is an error. Missing reply correlation is a draft
|
||||
warning; non-Reply messages MUST NOT have <span class="docutils literal">reply_to</span>.</p>
|
||||
<p>Create identifies the receiver lifeline's creation. Another use that can precede
|
||||
its creation on a shared execution path is an error. Delete identifies its
|
||||
destruction; another use that can follow it on a shared execution path is an
|
||||
error. These checks include conditional and parallel operands but exclude
|
||||
mutually exclusive alternatives. Ordering checks respect branch ownership; they
|
||||
MUST NOT flatten alternative branches into a false unconditional order. A Timer is
|
||||
<p>Create identifies the receiver lifeline's creation. An earlier guaranteed use
|
||||
of that lifeline is an error. Delete identifies its destruction; a later
|
||||
guaranteed use is an error. Ordering checks respect branch ownership; they MUST
|
||||
NOT flatten alternative branches into a false unconditional order. A Timer is
|
||||
local to its sender lifeline, has no global exchange or values, and has either
|
||||
no receiver or the same receiver as sender. Its nonempty expression describes
|
||||
the delay/deadline; an empty expression warns. Conditions and expressions are
|
||||
stored engineering annotations, not an executable simulation language.</p>
|
||||
<p>These ordering checks compare distinct stored occurrences along structural
|
||||
paths; they do not simulate repeated loop iterations. Repetition risks use
|
||||
<span class="docutils literal">reply_repetition</span> and <span class="docutils literal">lifeline_repetition</span> warnings separately. For example,
|
||||
a loop containing one Reply to an outer Call can warn about repetition without
|
||||
containing two distinct replies; a same-operand Create/use/Delete lifetime can
|
||||
repeat without its previous iteration being treated as an earlier stored use.</p>
|
||||
<p>Each MessageValue is <span class="docutils literal">{ path [@parameter]; field @data_field; expression "value expression"; }</span>.
|
||||
Its identity is the pair (path, field), so two uses of one Record can have
|
||||
different values for the same terminal field. Path contains enclosing DataField
|
||||
@@ -2649,41 +2640,6 @@ leaving dangling references. Deleting a sequence does not delete the semantic
|
||||
exchanges it references. Cross-layer counterparts are not owned descendants.
|
||||
Text edits describe a complete candidate and must remove or repair dependencies
|
||||
explicitly before Apply succeeds.</p>
|
||||
<section id="scenario-lifetime-presentation">
|
||||
<h3>8.1. Scenario lifetime presentation</h3>
|
||||
<p>Scenario lifetime geometry is derived from the stored occurrences and their
|
||||
sequence/operand ownership. It adds no model records, source annotations or
|
||||
history entries. A lifeline's identity header and neutral editing guide remain
|
||||
available before creation, after deletion and when its represented element is
|
||||
unfinished. The guide MUST be visually distinguishable from a live interval;
|
||||
it is an editing axis, not a statement that the participant exists.</p>
|
||||
<p>A directly ordered Create starts the receiver's live interval at its occurrence;
|
||||
Delete ends that receiver's interval at its occurrence. Create uses a named birth
|
||||
marker and Delete an X, each selectable through the original occurrence identity.
|
||||
The message's receiving endpoint MUST align with its marker, including self
|
||||
messages. A pre-existing lifeline without a creation has a live interval from
|
||||
below its header; without deletion it extends to the visible scenario bottom,
|
||||
including in an empty scenario. A missing lifecycle receiver MUST NOT be inferred from
|
||||
the exchange. An explicit local receiver remains meaningful when its exchange
|
||||
reference is unfinished.</p>
|
||||
<p>Operands MUST be projected independently from their enclosing entry context.
|
||||
A lifetime change in an alternative, optional, loop or parallel operand MUST
|
||||
NOT imply the same change in its displayed sibling or a definite lifetime
|
||||
after the fragment. When local structure does not establish one lifetime across
|
||||
the represented paths, the continuation is visually unresolved. A subsequent
|
||||
explicit Create or Delete in the enclosing sequence can establish a definite interval.
|
||||
Neither guard text, screen order nor a guessed loop count proves execution.
|
||||
ScenarioUse remains opaque: opening its definition may show its lifecycle,
|
||||
but a callee's geometry MUST NOT create or destroy a caller lifeline.</p>
|
||||
<p>Call activation is distinct from lifetime. A branch-local Delete clips that
|
||||
branch's visible activation at the death marker without inventing a Reply,
|
||||
removing an unresolved-call warning or terminating sibling paths.
|
||||
Solid activation bars are restricted to definite live intervals; an unresolved
|
||||
continuation retains its uncertainty/call diagnostic rather than depicting
|
||||
definite survival after a possible deletion. Selection,
|
||||
insertion slots, source edits and Undo/Redo retain the same semantic identities
|
||||
and validation boundary regardless of the rendered intervals.</p>
|
||||
</section>
|
||||
</section>
|
||||
<section id="persistence-and-explicit-migration">
|
||||
<h2>9. Persistence and explicit migration</h2>
|
||||
|
||||
+5
-54
@@ -680,28 +680,18 @@ references a Call occurrence in the same scenario. The call MUST precede the
|
||||
reply on a guaranteed common sequence/enclosing branch path. Reply lifelines
|
||||
reverse the call's sender/receiver; it may reuse the call's exchange in reverse
|
||||
or use an explicit reverse-direction exchange. A second reply to the same call
|
||||
that can follow another reply on a shared execution path, including conditional
|
||||
or parallel operands, is an error. Missing reply correlation is a draft
|
||||
on that guaranteed path is an error. Missing reply correlation is a draft
|
||||
warning; non-Reply messages MUST NOT have ``reply_to``.
|
||||
|
||||
Create identifies the receiver lifeline's creation. Another use that can precede
|
||||
its creation on a shared execution path is an error. Delete identifies its
|
||||
destruction; another use that can follow it on a shared execution path is an
|
||||
error. These checks include conditional and parallel operands but exclude
|
||||
mutually exclusive alternatives. Ordering checks respect branch ownership; they
|
||||
MUST NOT flatten alternative branches into a false unconditional order. A Timer is
|
||||
Create identifies the receiver lifeline's creation. An earlier guaranteed use
|
||||
of that lifeline is an error. Delete identifies its destruction; a later
|
||||
guaranteed use is an error. Ordering checks respect branch ownership; they MUST
|
||||
NOT flatten alternative branches into a false unconditional order. A Timer is
|
||||
local to its sender lifeline, has no global exchange or values, and has either
|
||||
no receiver or the same receiver as sender. Its nonempty expression describes
|
||||
the delay/deadline; an empty expression warns. Conditions and expressions are
|
||||
stored engineering annotations, not an executable simulation language.
|
||||
|
||||
These ordering checks compare distinct stored occurrences along structural
|
||||
paths; they do not simulate repeated loop iterations. Repetition risks use
|
||||
``reply_repetition`` and ``lifeline_repetition`` warnings separately. For example,
|
||||
a loop containing one Reply to an outer Call can warn about repetition without
|
||||
containing two distinct replies; a same-operand Create/use/Delete lifetime can
|
||||
repeat without its previous iteration being treated as an earlier stored use.
|
||||
|
||||
Each MessageValue is ``{ path [@parameter]; field @data_field; expression "value expression"; }``.
|
||||
Its identity is the pair (path, field), so two uses of one Record can have
|
||||
different values for the same terminal field. Path contains enclosing DataField
|
||||
@@ -2072,45 +2062,6 @@ exchanges it references. Cross-layer counterparts are not owned descendants.
|
||||
Text edits describe a complete candidate and must remove or repair dependencies
|
||||
explicitly before Apply succeeds.
|
||||
|
||||
8.1. Scenario lifetime presentation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Scenario lifetime geometry is derived from the stored occurrences and their
|
||||
sequence/operand ownership. It adds no model records, source annotations or
|
||||
history entries. A lifeline's identity header and neutral editing guide remain
|
||||
available before creation, after deletion and when its represented element is
|
||||
unfinished. The guide MUST be visually distinguishable from a live interval;
|
||||
it is an editing axis, not a statement that the participant exists.
|
||||
|
||||
A directly ordered Create starts the receiver's live interval at its occurrence;
|
||||
Delete ends that receiver's interval at its occurrence. Create uses a named birth
|
||||
marker and Delete an X, each selectable through the original occurrence identity.
|
||||
The message's receiving endpoint MUST align with its marker, including self
|
||||
messages. A pre-existing lifeline without a creation has a live interval from
|
||||
below its header; without deletion it extends to the visible scenario bottom,
|
||||
including in an empty scenario. A missing lifecycle receiver MUST NOT be inferred from
|
||||
the exchange. An explicit local receiver remains meaningful when its exchange
|
||||
reference is unfinished.
|
||||
|
||||
Operands MUST be projected independently from their enclosing entry context.
|
||||
A lifetime change in an alternative, optional, loop or parallel operand MUST
|
||||
NOT imply the same change in its displayed sibling or a definite lifetime
|
||||
after the fragment. When local structure does not establish one lifetime across
|
||||
the represented paths, the continuation is visually unresolved. A subsequent
|
||||
explicit Create or Delete in the enclosing sequence can establish a definite interval.
|
||||
Neither guard text, screen order nor a guessed loop count proves execution.
|
||||
ScenarioUse remains opaque: opening its definition may show its lifecycle,
|
||||
but a callee's geometry MUST NOT create or destroy a caller lifeline.
|
||||
|
||||
Call activation is distinct from lifetime. A branch-local Delete clips that
|
||||
branch's visible activation at the death marker without inventing a Reply,
|
||||
removing an unresolved-call warning or terminating sibling paths.
|
||||
Solid activation bars are restricted to definite live intervals; an unresolved
|
||||
continuation retains its uncertainty/call diagnostic rather than depicting
|
||||
definite survival after a possible deletion. Selection,
|
||||
insertion slots, source edits and Undo/Redo retain the same semantic identities
|
||||
and validation boundary regardless of the rendered intervals.
|
||||
|
||||
9. Persistence and explicit migration
|
||||
-------------------------------------
|
||||
|
||||
|
||||
+8
-10
@@ -1,6 +1,6 @@
|
||||
# Linux packaging and deployment inputs
|
||||
|
||||
The packaging command stages **already-built** desktop, server and source CLI executables for `x86_64-unknown-linux-gnu`. It runs on Windows PowerShell 5.1 or PowerShell 7 (including Linux) with Python 3.11+ standard library, Git, and the matching Rust/Cargo dependency cache. It does not build, download, install runtime libraries or launch the application. Python creates and reads back the tar archive; end users do not need Python, PowerShell or Rust.
|
||||
The packaging command stages **already-built** desktop/server executables for `x86_64-unknown-linux-gnu`. It runs on Windows PowerShell 5.1 or PowerShell 7 (including Linux) with Python 3.11+ standard library, Git, and the matching Rust/Cargo dependency cache. It does not build, download, install runtime libraries or launch the application. Python creates and reads back the tar archive; end users do not need Python, PowerShell or Rust.
|
||||
|
||||
Build and validate first in a source checkout. The current runtime validation baseline is Ubuntu 24.04 x86-64. A glibc symbol inventory is not a claim that every distribution with that glibc version supports the renderer and dialogs.
|
||||
|
||||
@@ -9,25 +9,23 @@ Build and validate first in a source checkout. The current runtime validation ba
|
||||
-Version 0.2.0-rc1 -BinaryDirectory ./target/release -Python python3
|
||||
```
|
||||
|
||||
For an explicit-target build, use `target/x86_64-unknown-linux-gnu/release`. A cross-build may supply another directory containing all three executables: `arcadia-studio`, `arcadia-server` and `arcadia`. Select the Rust compiler used for the intended build before packaging; its Linux target libraries, copyright reports and locked source/archive cache must be installed. Read-only `cargo metadata --locked --offline --filter-platform x86_64-unknown-linux-gnu` and Rust version/sysroot queries are the only Rust commands run by packaging. When packaging from Windows, `-Python C:/path/to/python.exe` selects Python explicitly.
|
||||
For an explicit-target build, use `target/x86_64-unknown-linux-gnu/release`. A cross-build may supply another directory containing both `arcadia-studio` and `arcadia-server`. Select the Rust compiler used for the intended build before packaging; its Linux target libraries, copyright reports and locked source/archive cache must be installed. Read-only `cargo metadata --locked --offline --filter-platform x86_64-unknown-linux-gnu` and Rust version/sysroot queries are the only Rust commands run by packaging. When packaging from Windows, `-Python C:/path/to/python.exe` selects Python explicitly.
|
||||
|
||||
The script refuses existing output/claim names. It creates `dist/ArcadiaStudio-<version>-x86_64-unknown-linux-gnu/`, a `.tar.gz`, and an adjacent `.sha256`. A failed attempt leaves its claim and any partial outputs for diagnosis; choose a fresh version or output root rather than overwriting them. Archives use sorted paths, zero timestamps and uid/gid, 0755 for the three Linux executables and 0644 for other files. Every entry is read back and compared against staging, including permissions. The same unchanged inputs on the same Python/zlib implementation produce the same bytes; this does not promise reproducible compiler output.
|
||||
The script refuses existing output/claim names. It creates `dist/ArcadiaStudio-<version>-x86_64-unknown-linux-gnu/`, a `.tar.gz`, and an adjacent `.sha256`. A failed attempt leaves its claim and any partial outputs for diagnosis; choose a fresh version or output root rather than overwriting them. Archives use sorted paths, zero timestamps and uid/gid, 0755 for the two Linux executables and 0644 for other files. Every entry is read back and compared against staging, including permissions. The same unchanged inputs on the same Python/zlib implementation produce the same bytes; this does not promise reproducible compiler output.
|
||||
|
||||
## Contents and provenance
|
||||
|
||||
All three executables, the MIT license, a package-specific README, complete tracked examples (including all water-pressure teaching/evidence files), deployment references and the reachable documentation closure are included. Relative Markdown/reST/HTML file links are checked before archiving; upstream license texts are preserved verbatim and their internal source-tree hyperlinks are outside that documentation check. Untracked private files, caches and unrelated harness history are not included merely because they exist on disk.
|
||||
Both executables, the MIT license, a package-specific README, complete tracked examples (including all water-pressure teaching/evidence files), deployment references and the reachable documentation closure are included. Relative Markdown/reST/HTML file links are checked before archiving; upstream license texts are preserved verbatim and their internal source-tree hyperlinks are outside that documentation check. Untracked private files, caches and unrelated harness history are not included merely because they exist on disk.
|
||||
|
||||
`package.json` records input hashes and static ELF dependencies. `SHA256SUMS.txt` inventories every file except itself; the archive checksum covers that inventory too. `dependency-notices.json`, `THIRD-PARTY-NOTICES.txt` and `licenses/` use the same strict collector as Windows: exact target-filtered normal/build dependency graph rooted at `oa-desktop`, `oa-server` and `oa-cli`, locked crate archives, actual source hashes, checksum-bound supplements and embedded fonts, Rust distribution notices and target-library hashes. Unknown sources, absent notices or changed supplements stop packaging. No Linux system libraries are bundled by this pipeline; their runtime distribution supplies them and its notices.
|
||||
`package.json` records input hashes and static ELF dependencies. `SHA256SUMS.txt` inventories every file except itself; the archive checksum covers that inventory too. `dependency-notices.json`, `THIRD-PARTY-NOTICES.txt` and `licenses/` use the same strict collector as Windows: exact target-filtered normal/build dependency graph, locked crate archives, actual source hashes, checksum-bound supplements and embedded fonts, Rust distribution notices and target-library hashes. Unknown sources, absent notices or changed supplements stop packaging. No Linux system libraries are bundled by this pipeline; their runtime distribution supplies them and its notices.
|
||||
|
||||
ELF preflight requires little-endian x86-64 executables/PIE, the glibc dynamic loader, no RPATH/RUNPATH and a reviewed set of direct libc/compiler-runtime imports. Unknown libraries require an explicit packaging design instead of being silently copied from a developer machine. Graphics/window/portal libraries loaded at runtime are outside that static import list.
|
||||
|
||||
Optionally pass `-BuildEvidence <json-file>`, containing nonempty `source_identity`, `profile`, `compiler`, exact `target`, and `binary_sha256` keyed by `arcadia-studio`, `arcadia-server` and `arcadia`. Hash strings must be lowercase SHA256. Target and binary hashes are checked; other fields remain explicit caller assertions. Omission records build provenance as unknown. Never label a development binary an optimized release because of its directory or package version.
|
||||
Optionally pass `-BuildEvidence <json-file>`, containing nonempty `source_identity`, `profile`, `compiler`, exact `target`, and `binary_sha256` keyed by `arcadia-studio` and `arcadia-server`. Hash strings must be lowercase SHA256. Target and binary hashes are checked; other fields remain explicit caller assertions. Omission records build provenance as unknown. Never label a development binary an optimized release because of its directory or package version.
|
||||
|
||||
## Native extracted-bundle checks
|
||||
|
||||
The source authoring CLI is `./arcadia`. Run `./arcadia --help` for `check`, `lint`, `stabilize` and `rename-alias`; each takes an explicit `.arcadia` entry file. Mutation commands preview by default and require `--write` to apply their candidate; `--json` produces a machine-readable report. Test writes on a separate project copy. CLI use does not require a display, a server or Rust. Include CLI check, preview, write and unchanged-repeat checks in extracted-bundle acceptance, alongside desktop and server checks.
|
||||
|
||||
Extract into a fresh user-writable Linux directory and run from an unrelated working directory. Verify SHA256SUMS, file permissions, `file`/`ldd` and actual desktop/runtime libraries. A typical Ubuntu runtime needs X11/XKB and Wayland clients and Vulkan loader/Mesa Vulkan drivers. Linux Open/Save uses the application's path-entry dialog and does not require an xdg-desktop-portal backend. A successful `ldd` alone is insufficient because graphics/window libraries can be loaded dynamically.
|
||||
Extract into a fresh user-writable Linux directory and run from an unrelated working directory. Verify SHA256SUMS, file permissions, `file`/`ldd` and actual desktop/runtime libraries. A typical Ubuntu runtime needs X11/XKB and Wayland clients, Vulkan loader/Mesa Vulkan drivers, and working session DBus plus an xdg-desktop-portal backend for file dialogs. A successful `ldd` alone is insufficient because these libraries can be loaded dynamically.
|
||||
|
||||
For a software Vulkan trial, first locate the installed lavapipe ICD; only if it exists, use:
|
||||
|
||||
@@ -35,7 +33,7 @@ For a software Vulkan trial, first locate the installed lavapipe ICD; only if it
|
||||
WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json ./arcadia-studio
|
||||
```
|
||||
|
||||
A compatible native GPU may run without these variables. WSLg uses real Linux user space with a particular display environment; record the actual X11/Wayland backend and do not infer support for all Linux sessions from one trial. Exercise Home walkthrough, Help/search, Linux Open/Save path entry, existing-file handling, SVG export overwrite confirmation, a multi-file project, diagram/source edits, Undo/Redo and reopening at normal/minimum window sizes. Compare complete saved model/source and inspect stderr, not merely whether a window appeared.
|
||||
A compatible native GPU may run without these variables. WSLg uses real Linux user space with a particular display environment; record the actual X11/Wayland backend and do not infer support for all Linux sessions from one trial. Exercise Home walkthrough, Help/search, native Open/Save dialogs, a multi-file project, diagram/source edits, Undo/Redo and reopening at normal/minimum window sizes. Compare complete saved model/source and inspect stderr, not merely whether a window appeared.
|
||||
|
||||
Run the server against a separate local data directory and unused loopback port. Follow [server configuration](server.md) for health, authentication, ETags, restart and backup/restore. Test create/import, a changed conditional update, unchanged no-op, stale/invalid rejection, and persistence after a clean restart. Record exact binary/archive/source/compiler hashes and platform. Packaging verification is separate from current-tree release acceptance.
|
||||
|
||||
|
||||
@@ -278,71 +278,3 @@ This is a selected design under implementation, not a native acceptance claim.
|
||||
Native reverse scenario Apply selected a dependency and inserted five overlapping behavior placements into an unrelated architecture view. Core transition correctness did not make that presentation useful. Select the counterpart of the explicit request root and resolve its nearest scenario/process/chain/state owner. Without an explicit destination diagram, keep the exact core candidate and open Behavior; preserve unrelated architecture views. Retain ordinary architecture transition placement and honor explicit diagram choices.
|
||||
|
||||
Automatically creating another architecture view would retain the wrong editing workspace and introduce unrequested model state. Merely navigating to Behavior after adding placements would conceal the presentation damage. Both alternatives are rejected. The selected correction retains one source transaction and Undo operation and guards stale canvas events. Revisit if mixed-root requests or realistic explicit destination workflows require a separate user choice; do not infer such a choice from dependency ordering. Test/build/native acceptance is recorded separately in release-behavior-transition-context-20260906.md.
|
||||
|
||||
## Property baselines and explicit alternative cases — 2026-09-11
|
||||
|
||||
Problem: complete independent bindings repeat shared engineering assumptions in
|
||||
every ordinary study case. The user requires practical text-only development and
|
||||
reduced cognitive load for small and large models. Evaluator implementation
|
||||
simplicity alone cannot justify that authoring cost.
|
||||
|
||||
Select property-owned base values/formulas and one-level case overrides as the next
|
||||
active-study contract to formalize. Every evaluator caller explicitly requests
|
||||
ModelBaseline or a particular Case identity; no active UI selection, unnamed
|
||||
default case or case-to-case inheritance determines meaning. The baseline is the
|
||||
model's explicit property values, not a mandatory extra record for entering the
|
||||
first value. Inherited formulas read the requested effective case. An explicit
|
||||
Unbound override shadows a known base; clearing an override uses the base.
|
||||
|
||||
The [executed comparison](release-analysis-context-experiment-20260911.md) passed
|
||||
218 assertions. Twenty cases with20 fixed/two varying inputs used62 binding rows
|
||||
under B versus480 under D, with identical results. Updating one shared assumption
|
||||
required one B binding versus20 D bindings. Reusable predicates eliminated D's
|
||||
threshold-edit disadvantage, so repeated formulas are not the reason for selection.
|
||||
|
||||
Retain an explicit complete independent freeze/export for intentionally separate
|
||||
assumptions. It must capture effective values/formulas and the full exact definition
|
||||
environment, including package pins; copying only case rows leaves hidden live
|
||||
dependencies. Captured revisions and per-input origins must make propagation
|
||||
visible. Effective dependency/cycle validation and stale result handling must cover
|
||||
the whole requested context.
|
||||
|
||||
Reject compulsory complete cases for ordinary shared-assumption modeling; retain
|
||||
their useful isolation as an explicit independent snapshot. Reject arbitrary
|
||||
inheritance and UI-dependent evaluation. Do not import the tiny experiment
|
||||
interpreter as production architecture. Exact check identity, type/operator
|
||||
algebra, source syntax, library closure/compatibility, transitions and public
|
||||
snapshot formats still require the coordinated precise contract. Current source7
|
||||
does not implement this decision.
|
||||
|
||||
Revisit if complete text/native tasks expose unacceptable hidden propagation,
|
||||
wrong-context edits or inability to preserve reproducible independent studies.
|
||||
The comparison establishes semantic/text maintenance evidence, not human task
|
||||
timings or full quantity/library integration acceptance.
|
||||
|
||||
## Scenario lifetime rendering integration — 2026-09-11
|
||||
|
||||
Codex-ytr83r /root. Adopt the [scoped lifecycle proposal](release-scenario-lifecycle-design-20260910.md)
|
||||
as derived presentation over existing source-7 semantics. Always retain a neutral
|
||||
identity header/editing guide; depict definite live intervals separately, with
|
||||
original-occurrence Create capsules and Delete crosses. Alternative, optional,
|
||||
loop and parallel operands inherit their enclosing entry independently and leave
|
||||
an unresolved continuation after a local lifetime change. Reused scenarios remain
|
||||
opaque. This does not execute guards or add model/source/history records.
|
||||
|
||||
Full-height ordinary spines fail to communicate creation/deletion; one global
|
||||
earliest-birth/latest-death range would falsely order exclusive operands. Both
|
||||
alternatives lose to the scoped projection. The [independent review](release-scenario-lifecycle-independent-review-20260911.md)
|
||||
requires true visible root bounds, a complete rendering lane map and local
|
||||
activation clipping at Delete; insertion bounds/eligible lanes must keep their
|
||||
separate roles. Receiver endpoints and selectable markers must agree, including
|
||||
self messages. Those findings determine the integration contract in language
|
||||
section 8.1 and the real-Scene/native acceptance fixtures.
|
||||
|
||||
Core/source semantics are unchanged. In section 4.3, replacing the old
|
||||
guaranteed-only lifetime/duplicate-reply wording with possible shared-path wording
|
||||
corrects documentation to `validation.rs` and the existing conditional/parallel
|
||||
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.
|
||||
|
||||
+73
-67
@@ -1,77 +1,83 @@
|
||||
> Superseded on 2026-09-11. This document is a preserved historical checkpoint.
|
||||
> Live plans, priorities, ownership and handoffs now reside in [Gitea issue 1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-410) and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
|
||||
> Do not resume synchronizing this ledger. See [STATE.md](STATE.md) for the integrated checkpoint.
|
||||
|
||||
# First release execution plan
|
||||
|
||||
Updated 2026-09-11 for the published Codex-ytr83r B4 source checkpoint.
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md) defines the full outcome;
|
||||
[STATE.md](STATE.md) scopes evidence/ownership and
|
||||
[TASK_LEDGER.md](TASK_LEDGER.md) separates facts from decisions.
|
||||
Updated 2026-09-11, Codex-9e8r6t `/root`.
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md) defines the outcome;
|
||||
[TASK_LEDGER.md](TASK_LEDGER.md) separates evidence from assumptions;
|
||||
[STATE.md](STATE.md) owns active leases and results.
|
||||
|
||||
## Immediate integration and verification
|
||||
## Immediate implementation and acceptance
|
||||
|
||||
1. Preserve completed integration c81e285/e207b04/b853a1e and reviewed B4 source
|
||||
52f6e17 on codex/release1-ytr83r. Do not replay equivalent3978/d114 changes.
|
||||
CLI-only01b76a1 was reviewed and imported as023e3a8 without core/dependency
|
||||
changes. Preserve the new AGENTS maintenance instruction and exact goal bytes.
|
||||
2. Core433, independent lifecycle-oracle admission, focused lifecycle22 and full
|
||||
desktop675 passed on this host. Production freeze2908405 is unchanged by
|
||||
evidence checkpoint3ade397. GUI build, both-layer history/reopen and corrected
|
||||
workspace strict Clippy passed. Finish the affected test and Unicode/native
|
||||
workflows below. Do not repeat the passed suites without a
|
||||
source change or new finding. Root serializes writers and records exact scope.
|
||||
3. Use the imported evidence2ec creation-oracle v2 for native Undo comparisons.
|
||||
Reject unknown/changed detached files while allowing exact files
|
||||
retained from an explicit pre-Undo origin. Preserve full Project and reachable
|
||||
SourceBundle equality and original role identities through Redo/reopen.
|
||||
4. Exercise current Windows UI at minimum/wide sizes: creation name/owner/viewpoint,
|
||||
one-step history, stale/raw-draft rejection, first Escape/modal priority,
|
||||
recovery, clicked root/ALT action/observation/reuse, exact caller return,
|
||||
same-size Fit and natural subsequent scrolling. Use independent semantic
|
||||
predictions, not only screenshots or serialization of observed outputs.
|
||||
5. Review current external Gitea handoffs before importing staged prerequisites,
|
||||
empty-state actions, contextual chooser or source CLI. Validate final combined
|
||||
workflows and preserve explicit ownership/qualification semantics.
|
||||
6. After actual B4 test/native acceptance, prepare a distinct runnable Windows
|
||||
prerelease with current-toolchain notices and exact executable/source hashes.
|
||||
Existing MCP credentials have now completed a scoped branch push and remote
|
||||
readback; release attachment upload still needs concrete-artifact verification.
|
||||
Codex-9e8r6t retains the earlier named-authoring prerelease ownership.
|
||||
7. Linux setup/downloads/builds/checking are paused by direct user instruction.
|
||||
Wait for the user to resolve WSL2 networking and explicitly instruct resumption.
|
||||
Preserve installed Ubuntu, exact VirtioProxy configuration and complete/partial
|
||||
offline inputs. Networking remains unresolved; preparation is not Linux
|
||||
acceptance. Follow the current WSL/offline records rather than old reboot plans.
|
||||
8. Incorporate the owner clarification in issue2: process/scenario interaction
|
||||
should use consistent direct canvas workflows while preserving domain
|
||||
constraints. Codex-956atf owns the shared canvas design; B4 rendering and
|
||||
prerequisite forms support it but do not close this requirement.
|
||||
1. Preserve the verified creation integration and matching GUI stage, committed
|
||||
as 4bb4aaf and transferred with independent anonymous readback/hash verification. Final immutable
|
||||
desktop653/653 passed after creation ancestry/guide changes, alongside focused
|
||||
creation21/guide3. StrictClippy/fmt pass after a behavior-equivalent history
|
||||
rewrite; affected history63 and GUI build also pass. Preserve exact artifact
|
||||
scopes and serialize future Cargo/source leases.
|
||||
2. Named process/scenario/machine creation, exact Undo/Redo and nine-file reopen
|
||||
have passed at actual minimum size. Wide/minimum cancellation passed; SA draft
|
||||
crash recovery is active. Address observed blank canvases and prerequisite
|
||||
creation detours through the staged-source plan: source subform retains the
|
||||
parent draft and final adoption atomically creates prerequisite and consumer.
|
||||
First slice covers Lifeline, process Work and machine owner, with semantic
|
||||
first-action prompts. Independent review precedes final integration/native QA.
|
||||
3. Operate the rebuilt app at minimum/normal sizes. Complete the saved basic
|
||||
supplementary move/camera/invalid-draft and minimum-size story; the main basic
|
||||
model and explicit transition passed full Project+SourceBundle, Undo/Redo and
|
||||
reopen on candidate3 at wide size. Exercise new
|
||||
scenario workspace, creation and caller return with real inputs and saved
|
||||
model evidence, then correct and reverify concrete defects.
|
||||
4. Integrate standalone B4 scenario lifetimes and the external independent
|
||||
scenario contract without overwriting local layout work. Resolve source-derived
|
||||
lifetime versus insertion bounds, activation-after-Delete and lane-map issues.
|
||||
Close remaining
|
||||
contextual operand, missing-source and state-naming workflow gaps identified
|
||||
by the current behavior audit and native study.
|
||||
|
||||
## Capability milestones
|
||||
## Foundational semantic additions
|
||||
|
||||
| Outcome | Work and acceptance |
|
||||
| --- | --- |
|
||||
| Usable direct behavior/state engineering | Continuous ordered/explicit process authoring, clicked root/nested scenario insertion, exact reuse bindings/caller return, state naming/creation, recovery and source-preserving history. Complete B4 lifetime rendering with actual lifetime bounds, branch-local Delete activation clipping, incomplete lanes and self-arrow endpoints. |
|
||||
| Precise engineering analysis | Refine property-owned baseline values/formulas plus explicit ModelBaseline/Case one-level overrides. Close quantity kind/unit/value, exact/approximate numerical policy, dependencies/cycles, false/missing/error, complete freeze and definition provenance. Implement coherent validated core/source/REST/GUI/history contracts. An exact-duration slice alone cannot satisfy the full goal. |
|
||||
| Maintained reusable libraries | Resolve source-only normalization, pinned offline definitions/closure, provenance versus checked same-origin compatibility, source→local instance maps, explicit three-way updates/local divergence, subjectless analysis closure and OA/SA mounted-data transition policy. Demonstrate two projects/two instances, cosmetic and semantic upgrades, conflicts/detach and rollback. |
|
||||
| Practical complete textual and graphical tasks | Measure small/large text-only creation, cross-file reference navigation, safe alias rename and lossless patch preview. Integrate source CLI and bounded contextual choices; actual keyboard/IME, repeated names, stale events, recovery and minimum geometry must pass. Keep offline guide/examples and normative source documentation aligned. |
|
||||
| Complete engineering stories | Close OA↔SA transition/reconciliation, typed data/interfaces/state, artifacts, saved views, hosted workflows and tutorial tasks against the full current goal. Confirm parity and precise specification rather than inheriting historical exclusions. |
|
||||
| Reproducible Windows/Linux release | Current tests/format/strict Clippy, reST/OpenAPI/migration/example gates; real GUI and REST transport/restart/ETag conflict acceptance; matching optimized desktop/server packages, complete notices/archive hashes and clean-runtime launch. Fresh independent requirements/domain/parity/architecture/UX/runtime review resolves material findings before release. |
|
||||
The updated objective explicitly requires functional constraints/calculations,
|
||||
quantities/units and maintained model libraries. Current metadata/includes are
|
||||
insufficient. This work runs independently of useful behavior improvements.
|
||||
|
||||
Milestones run adaptively where independent work helps. Analysis and libraries
|
||||
share definition/identity assumptions and must converge before broad migrations.
|
||||
Other-host slices remain coordinated through issue #1 and Wiki. Native/Cargo
|
||||
leases are host-local; source ownership and exact transfer heads are shared.
|
||||
The newest objective also requires complete uncluttered text-only workflows.
|
||||
Combine the local three-task usability audit with the other host's language/
|
||||
identity/API design. Two concise current-grammar recipes now pass their bounded
|
||||
REST identity/source/persistence experiment. Next evaluate
|
||||
cross-file definitions/references, safe alias rename and identity-preserving
|
||||
patch preview on repeated-name modular models. Do not equate shorter examples
|
||||
or parser throughput with task usability, and do not silently change omissions.
|
||||
|
||||
## Evaluator boundaries and replanning
|
||||
- Compare real candidate semantics before large changes. Use primary Capella,
|
||||
SysML and quantity/unit evidence, with explicit compatibility limits.
|
||||
- Falsify candidates through mass/power/latency budgets, mixed/affine units,
|
||||
unknown/error/false results, dependency cycles, reference identity and bounded
|
||||
evaluation. Separate descriptive existing text from executable declarations.
|
||||
- Probe libraries through two projects/two instances, pinned offline content,
|
||||
exports/dependency closure, bindings, distinct origin/local identities, updates
|
||||
with local divergence, detachment and exact transactional rollback.
|
||||
- Run a small pure-core experiment for the selected candidate. Independent
|
||||
criticism must precede broad schema/source/API migration. Retain current typed
|
||||
Project, Command and source-writer boundaries unless evidence requires change.
|
||||
- Implement the ratified contracts across validation, visitors, deletion,
|
||||
identity, OA/SA transitions, source/reST, schema/REST, ergonomic graphical
|
||||
authoring and help. Add real examples and independent parity acceptance.
|
||||
|
||||
Use authoritative source semantics, exact UUID/source preservation and actual
|
||||
user workflows. Source-only experiments are not accepted new grammar; mounted
|
||||
widget tests are not native readability; synthetic packaging checks are not
|
||||
Linux runtime. Preserve old environmental failures as evidence without inventing
|
||||
current blockers. Continue independent work when platform or transfer work waits.
|
||||
## Integration and release
|
||||
|
||||
[Archived prior plans](history/2026-09-11-ytr83r-integration/README.md) preserve
|
||||
all previous milestone detail. Update this plan when a counterexample changes
|
||||
the right next action; do not lower completion criteria after substantial effort.
|
||||
- Publish coherent commits and verified bundle transfers through Gitea; keep
|
||||
concise host-scoped handoffs and Wiki. Do not overwrite another host's branch.
|
||||
- Refresh relevant source/schema/example/doc gates and run current core,
|
||||
desktop/server and real REST transport/restart/ETag/draft-conflict workflows.
|
||||
- Build matching optimized desktop/server packages. Regenerate complete notices
|
||||
for actual inputs and verify archive contents, hashes and clean-runtime launch.
|
||||
Historical ELF fixtures and build preparation do not replace execution.
|
||||
- Obtain a viable Linux environment and run current GUI/server workflows there.
|
||||
Supported WSL2 failed; no repeated rejected workaround. Continue independent
|
||||
work while the environment question is pending.
|
||||
- Conduct fresh independent requirements, semantic, usability, architecture,
|
||||
correctness, runtime and documentation reviews. Resolve all material findings,
|
||||
reverify affected behavior and only then declare release completion.
|
||||
|
||||
Historical milestones and evidence remain intact in
|
||||
[the dated plan archive](history/2026-09-10-ledgers-before-second-consolidation/PLAN.md).
|
||||
They do not supersede current user scope or imply current-tree acceptance.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Current engineering work and evidence
|
||||
|
||||
Start with the [user goal](../PROJECT_GOAL.md), [current status](STATE.md),
|
||||
[execution plan](PLAN.md), [task ledger](TASK_LEDGER.md) and
|
||||
[decisions](DECISIONS.md). Follow [AGENTS.md](../AGENTS.md) and current
|
||||
[Gitea coordination](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1)
|
||||
before changing shared work. Dated reports describe their own source, host and
|
||||
artifact; they are not current instructions or acceptance of later changes.
|
||||
|
||||
## Runnable milestones
|
||||
|
||||
- [Windows Studio and REST server authoring preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.authoring.20260911)
|
||||
- [Standalone Windows source CLI preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/cli-v0.2.0-preview.authoring.20260911)
|
||||
|
||||
These are unfinished development milestones. Their release notes and validation
|
||||
attachments define exactly what was shipped and tested. Full OA/SA engineering,
|
||||
analysis, maintained libraries and current Linux acceptance remain unfinished.
|
||||
|
||||
## Current local evidence
|
||||
|
||||
| Question | Authoritative record |
|
||||
| --- | --- |
|
||||
| Which source was integrated? | [Integration audit](release-integration-ytr83r-20260911.md), [CLI import review](release-cli-integration-ytr83r-20260911.md) |
|
||||
| What does lifetime rendering mean? | [Implementation and corrected review findings](release-lifecycle-render-ytr83r-20260911.md), [language specification](../docs/language.rst) |
|
||||
| What will native behavior be compared against? | [Independent fixture and procedure](fixtures/scenario-lifecycle-native/README.md), [exact pre-native evidence](experiments/lifecycle-native-ytr83r-20260911/) |
|
||||
| Which checks ran on this host? | [Host build record](release-host-bootstrap-ytr83r-20260911.md), [current status](STATE.md) |
|
||||
| What remains for Windows packaging? | [Current runtime/notice preflight](release-b4-package-preflight-ytr83r-20260911.md), [publication path](release-publication-path-ytr83r-20260911.md) |
|
||||
| Is Linux available? | [WSL outcomes and current user pause](release-wsl-postboot-ytr83r-20260911.md), [preserved offline inputs](release-linux-offline-preparation-ytr83r-20260911.md); setup/build/checking waits for the user's networking-resolution signal |
|
||||
| What changed for Linux delivery? | [Three-binary CLI packaging and checks](release-linux-cli-packaging-ytr83r-20260911.md), [portable REST restart probe](release-rest-probe-portability-ytr83r-20260911.md); native execution remains pending |
|
||||
| Which bounded follow-ups are proposals? | [Explicit Action allocation](release-qualified-action-design-ytr83r-20260911.md), [inheritance scaling diagnosis](release-inheritance-scaling-diagnosis-ytr83r-20260911.md) |
|
||||
|
||||
External staged-source, chooser and analysis/library owners maintain their current
|
||||
handoffs in Gitea and the [shared Wiki](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/wiki/Home).
|
||||
Import exact coherent source checkpoints before transferring conclusions to this checkout.
|
||||
|
||||
## History
|
||||
|
||||
[Archived integration ledgers](history/2026-09-11-ytr83r-integration/README.md)
|
||||
preserve source-specific facts and earlier ownership. Existing dated evidence and
|
||||
Git history remain available. Preserve unique findings and update links before
|
||||
moving or removing material; active issue #1 remains open for the unfinished goal.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Resume after the user-requested reboot — 2026-09-11
|
||||
|
||||
Codex-ytr83r /root. The complete first-release goal remains active and unfinished.
|
||||
Read AGENTS.md, PROJECT_GOAL.md, current Gitea issues1/2 and Wiki before resuming.
|
||||
This is a pause for the user's reboot, not a completed milestone or failed test.
|
||||
|
||||
## Saved source and evidence
|
||||
|
||||
Branch: codex/release1-ytr83r. Production source is B4 lifetime rendering52f6e17
|
||||
plus CLI01b imported as023e3a8. b96286e records exact core/oracle evidence and
|
||||
current harness index. d041212 preserves owner instruction-sync history and the
|
||||
byte-exact supplied PROJECT_GOAL. a9a5768 records the Linux trial and contextual
|
||||
source integration review. Later checkpoint commits add this resumption note.
|
||||
|
||||
Core all-targets passed433 tests, one intentionally ignored probe; the optional
|
||||
probe separately passed. The independent lifecycle oracle and all six frozen
|
||||
baseline/rename files passed before any native GUI launch. See the harness index.
|
||||
No current desktop compile/test/native acceptance has been established.
|
||||
|
||||
## Interrupted Windows build
|
||||
|
||||
Cargo session93786 was interrupted at the user's request while compiling Iced
|
||||
dependencies (last log entry cc1.4.5). It exited1 from interruption; no final gate
|
||||
status JSON was emitted. A readback found no cargo.exe/rustc.exe process. Exact
|
||||
partial log and explicit interruption record are in
|
||||
experiments/lifecycle-native-ytr83r-20260911/. Preserve cached target outputs.
|
||||
|
||||
Use the existing ignored task-local toolchain, one Cargo job, after checking no
|
||||
other writer is active. From the repository root in PowerShell:
|
||||
|
||||
```powershell
|
||||
& '.local\run-gate.ps1' -GateName 'ytr83r-desktop-lifecycle-resume' -CargoArgs @('test','--locked','--offline','-p','oa-desktop','scenario_lifecycle','--','--test-threads=1')
|
||||
```
|
||||
|
||||
The wrapper loads .local/toolchain/env.ps1. Then run appropriate direct-canvas,
|
||||
full/strict desktop, server/CLI and GUI build gates serially. Review the new
|
||||
typography report: wide Unicode Create labels can overflow the fixed capsule.
|
||||
The proposed clipping correction is not implemented; preserve existing fixtures
|
||||
and define separate expected Unicode cases before native output. Actual Windows
|
||||
wide/minimum/Fit/Save/Undo/Redo/reopen acceptance and B4 packaging remain pending.
|
||||
|
||||
## Linux after reboot
|
||||
|
||||
Recheck wsl --status and wsl --list --verbose. Before reboot, no distribution was
|
||||
installed; WSL reported virtualization unavailable and Windows had a pending
|
||||
reboot. Hardware virtualization must be available (firmware or nested VM host).
|
||||
No host setting was changed and no reboot was performed by the agent.
|
||||
|
||||
Prefer a working WSL2 Ubuntu for real Linux build/runtime validation. The verified
|
||||
Ubuntu image and QEMU cache remain under .local/linux-host-ytr83r. Its bounded
|
||||
UEFI/TCG pilot ended without guest-ready evidence; all owned pilot processes
|
||||
stopped. Do not repeat failed delivery or infer Linux acceptance from the image.
|
||||
|
||||
## Coordination and next integration
|
||||
|
||||
Codex-9e8r6t owns contextual prerequisites; df6f5956 is available locally and has
|
||||
a reviewed minimal import path, with two B4-preserving merges. Later short-host
|
||||
layout and Save-procedure corrections are separate; inspect the current remote
|
||||
handoff before import. Codex-956atf owns chooser, shared constrained canvas design
|
||||
for issue2, and analysis/library contracts. Local audit agents have stopped their
|
||||
assigned work. They must recheck coordination before taking new tasks.
|
||||
|
||||
The two published Windows authoring/CLI prereleases remain available. No B4
|
||||
release should be published until actual current tests/native acceptance pass.
|
||||
+186
-39
@@ -1,55 +1,202 @@
|
||||
# Integrated checkpoint — 2026-09-11
|
||||
# Progress Ledger — first release
|
||||
|
||||
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.
|
||||
Updated 2026-09-11, Codex-9e8r6t `/root`, Windows host.
|
||||
**Release remains unaccepted; meaningful product and integration work is active.**
|
||||
|
||||
## Verified code and capabilities
|
||||
## Objective and current source
|
||||
|
||||
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).
|
||||
Deliver the complete OA/SA first release in [PROJECT_GOAL.md](../PROJECT_GOAL.md).
|
||||
The latest authoritative attachment is
|
||||
`815ff7b9-73c4-41a2-a809-635ecf8389d6/goal-objective.md`, read in full and preserved
|
||||
in that file. It requires constraints/calculations, quantities/units and reusable
|
||||
libraries, plus uncluttered human/AI text-only modeling at small and large scale.
|
||||
Long homogeneous lists and poor behavior editing remain explicit concerns.
|
||||
|
||||
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).
|
||||
Local branch: `codex/release1-9e8r6t`, base `1797516`, committed head `4bb4aaf`.
|
||||
Named behavior creation, its guide and independent oracle, concise text-first
|
||||
examples and prior integration evidence are committed and remotely transferred.
|
||||
Its final ancestry/guide correction passed full653; post-lint history63, strict
|
||||
Clippy/fmt and matching GUI build passed. Current uncommitted work corrects the
|
||||
oracle's retained-file rule, clarifies source-7 constructor defaults, records
|
||||
native creation and implements contextual prerequisite creation. Preserve all
|
||||
workers' edits. No open Gitea PRs at the latest read, comments through138.
|
||||
|
||||
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.
|
||||
## Completed work and scoped evidence
|
||||
|
||||
B4 renders local lifetimes, branch-local activation termination, neutral incomplete lanes, Create capsules and Delete markers with original occurrence identity. Real Windows OA/SA six-case matrices and separate name-only Save/Undo/Redo/reopen workflows passed complete independent Project, reachable-source and physical-file comparisons. These are bounded fixture checks, not complete product usability acceptance.
|
||||
- Palette inline naming and focused Escape: `7d1df4f`, independently published as
|
||||
`codex/palette-authoring-20260910`. Candidate 2 focused tests, GUI build and
|
||||
strict desktop Clippy passed. Its native F2 rename/Save/Undo/Redo matched exact
|
||||
three-file expectations; Entity creation matched the complete independent
|
||||
model/source oracle. Candidate3 completed ordinary Activity, existing placement,
|
||||
allocation, direct Interaction and F2: complete basic model/four sources PASS.
|
||||
Explicit SA actor/destination transition passed complete model/six sources;
|
||||
Undo four-source baseline, Redo six-source result and reopen all passed. Six
|
||||
physical files are byte-identical across reopen/reuse-preview Cancel/Save.
|
||||
Immutable checkpoints/logs: `.local/native-qa/palette-20260910-v3/`.
|
||||
- Recovery naming autofocus: `31b7dc3`, 13 focused checkpoint tests passed after
|
||||
integrated compilation. Its new autofocus behavior still needs native check.
|
||||
- Other-host behavior/topology/scenario/history: original `01bb52a` integrated
|
||||
as `057eddd`; constructor cache fix `b6b76ef` integrated as `a70de55`; explicit
|
||||
history seed fix `4ecad78` cherry-picked as `81668f9`.
|
||||
- Corrected immutable desktop artifact completed **631/631 passed**,408.18s,
|
||||
exit0; all three prior618-artifact failures are fixed. Exact artifact/logs in
|
||||
`.local/scenario-workspace-20260910/tests-81668f9/`. It covers3978bbe workspace,
|
||||
Back/OpenUse guards and both history fixes, but not later Sensor/creation work.
|
||||
- A real same-size owner/document Sensor regression led to `d114a27`; affected
|
||||
scenario53/53 passed. Current native candidate3 predates that correction.
|
||||
Mounted workspace tests cover500×570 and wide geometry; native current-build
|
||||
scenario acceptance remains pending.
|
||||
- Named local creation passed final21/21 and guide3/3 after ancestry-label
|
||||
correction. The matching immutable desktop suite passed **653/653**,362.83s,
|
||||
zero failures/ignored; exact artifact864B0F…E86/log/result under
|
||||
`.local/behavior-creation-20260911/tests-final`. Independent integration review
|
||||
found no additional correctness blocker. StrictClippy/all-targets/-Dwarnings
|
||||
and workspacefmt pass after one behavior-equivalent history_fields let-chain
|
||||
correction; affected history63/63 and matching GUI rebuild pass. Current native
|
||||
GUI is `gui-verified` SHA D4D9C2BD…B20C31. At actual 1100×700 client size,
|
||||
named OA Process, explicit ParticipantInteraction Scenario with capability
|
||||
context and SA owner-linked State machine plus requested first view passed
|
||||
complete independent Project/SourceBundle checks at 7/8/9 files. Each single
|
||||
Undo/Redo preserved exact expected semantics, sources and role identities.
|
||||
Reopen/Save retained all nine physical source files byte-for-byte. Wide/minimum
|
||||
draft cancellations retained the initial six files exactly. Detailed report:
|
||||
[native creation](release-behavior-creation-native-20260911.md).
|
||||
- Independent creation oracle v2 corrects a harness assumption: Save retains
|
||||
known detached source files after Undo. Full Project and reachable source
|
||||
equality remain mandatory; the complete physical inventory must equal the
|
||||
explicit pre-Undo checkpoint overlaid with expected reachable bytes. Unknown,
|
||||
changed or missing retained files fail. No product defect was hidden by this
|
||||
correction; see [oracle review](release-behavior-creation-oracle-20260911.md).
|
||||
- Fresh source-7 constructor probes passed 54 explicit/omitted comparisons and
|
||||
12 required-field refusals across OA/SA, with complete typed models, canonical
|
||||
reparse and exact authored no-op/name-edit retention. Constructor and Scenario
|
||||
default wording now describes current behavior; strict HTML regeneration
|
||||
passed. Other constructor families are explicitly outside this evidence.
|
||||
See [default review](release-constructor-defaults-review-20260911.md).
|
||||
- Source schema checks passed: 72 fixtures, 204 schemas, 14 families, 90 scalar
|
||||
leaves. API/language reST rendered with strict docutils without warnings.
|
||||
- Packaging committed `b98f135`: portable Linux adapter, shared package checks,
|
||||
Docker input validation and exact upstream notices. Independent review found
|
||||
and fixed five concrete defects. Python 11/11 and PowerShell contracts pass.
|
||||
Historical Windows-input ZIP has 557 files; real Linux notices close over
|
||||
339 packages/681 files. Linux archive adapter used inert ELF fixtures, not
|
||||
actual current application binaries. Exact Fira/Cantarell notice Git bytes
|
||||
were independently verified. Current-source notices need regeneration.
|
||||
- Independent native Capella creation study completed, saved and closed cleanly;
|
||||
report `release-capella-creation-study-20260910.md` is complete. Viewpoint-first
|
||||
naming and distinct local create/existing selection inform the implementation.
|
||||
- Textual usability audit completed three static tasks and example counts. The
|
||||
existing grammar already permits omitted initial IDs/defaults; key gaps are
|
||||
cross-file reference context, safe alias refactoring and reviewed patches.
|
||||
Source7 EBNF/schema documentation and viewpoint omission guidance are corrected;
|
||||
generated language HTML passed strict docutils. Both concise snippets passed
|
||||
26 real REST requests: identity-only stabilization, exact no-op/name edit,
|
||||
stale/invalid nonmutation and restart persistence. All120 current core/server
|
||||
files match the preserved source7 binary's documented snapshot. This closes
|
||||
the bounded recipe gap, not large-model or native text-editor acceptance. The
|
||||
verified recipe is now `examples/text-first` and in the language reference.
|
||||
|
||||
- [Native lifecycle observations and limits](release-lifecycle-native-ytr83r-20260911.md).
|
||||
- [OA evidence archive](lifecycle-native-ui-oa-archive-20260911.md) and [SA evidence archive](lifecycle-native-ui-sa-archive-20260911.md).
|
||||
- [Lifecycle implementation and independent findings](release-lifecycle-render-ytr83r-20260911.md).
|
||||
- [Creation and source integration evidence](release-staged-source-integration-ytr83r-20260911.md).
|
||||
Detailed evidence: [integrated gates](release-integrated-gates-20260910.md),
|
||||
[palette native](release-palette-native-20260910.md),
|
||||
[scenario workspace](release-scenario-workspace-20260910.md),
|
||||
[creation design](release-local-behavior-creation-20260910.md),
|
||||
[packaging implementation](release-packaging-implementation-20260910.md),
|
||||
[independent packaging review](release-packaging-independent-review-20260910.md).
|
||||
|
||||
Source `321897fd55af9e07afa2c6ebf1e50be49e86f7d9` integrates the published contextual source-creation/minimum-card corrections from b2c3e540 with B4 and the header clipping correction. Its own Windows desktop all-targets gate passed 704 tests. [Exact selective integration, independent union review and Windows results](release-contextual-integration-update-ytr83r-20260911.md).
|
||||
## Active ownership and execution
|
||||
|
||||
Published production `865483b4c66361a01fe0d9e731859220bafb4136` adds the reviewed child-work correction: first refinement transfers existing Allocation and RoleAssignment identities, metadata and artifact references to the child; later siblings inherit only a unique performer. OA/SA regressions cover nested children, roles, folded grouping, unallocated/mixed controls and exact history. The old extracted Studio `BF9C2E61…` passed OA first-child, sibling and grandchild Save/Undo/Redo checks, then confirmed OA reopen/Save and the SA first/sibling/grandchild, Undo/Redo and reopen/Save continuation. Original identity carriers and complete source/physical snapshots matched. These are native results for **865483b/BF9**, not a57/69DB. [Original OA evidence](experiments/native-child-oa-865483b-20260911/README.md), [closed continuation](experiments/native-child-continuation-865483b-20260911/README.md), [independent continuation review](release-native-child-continuation-review-20260911.md), [fixture/oracle](fixtures/child-work-native/README.md) and [issue4](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4) preserve the exact scope.
|
||||
- Root: integration, common ledgers, source/API checks and exclusive native input.
|
||||
Full653 is terminal PASS. V4 creation/Undo/Redo/reopen is complete; actual SA
|
||||
pending-draft crash recovery is active on the same verified GUI. The forced
|
||||
interruption targeted only isolated QA PID5184 after checkpoint persistence;
|
||||
restarted PID1284/window6161106 uses the same private profile and runtime.
|
||||
Recovery is now terminal PASS: full draft fields/IDs retained, autofocus,
|
||||
first Escape and a fresh Save As preserve the complete nine-file model. The
|
||||
current wide GUI is saved on `checkpoint-recovery-cancel/project.arcadia`;
|
||||
treat that checkpoint as immutable and reopen a work copy before editing.
|
||||
- `release_audit`: prior creation and constructor reports frozen. Now owns the
|
||||
staged prerequisite creation implementation for Scenario Lifeline/process Work/
|
||||
New machine owner, semantic first-action prompts, relevant App/recovery/guide
|
||||
wiring and focused tests. Cargo writer is reserved to this worker when needed;
|
||||
freeze source during tests and request independent review before integration.
|
||||
- `behavior_audit`: v2 native oracle frozen, warnings-denied compilation and
|
||||
falsifiers pass, executable056EEFD…F6A36. Now independently challenges staged
|
||||
source semantics and expected behavior; no production/Cargo/native ownership.
|
||||
- `palette_finish`: native report/evidence frozen: 13 creation oracle commands
|
||||
and 19 separate recovery checks pass. Now owns new `crates/oa-cli` for local
|
||||
source check/lint, preview/explicit-write stabilization and alias rename/diff.
|
||||
Uses existing source-file diagnostics/expectations/journal writer, no grammar
|
||||
or core changes. Root owns workspace/lock integration; serialize Cargo with
|
||||
release_audit. Preview UUIDs remain candidates until that invocation writes.
|
||||
- Separate host Codex-956atf: standalone scenario lifetime projection, independent
|
||||
native scenario oracle, quantity/calculation design and library design. It has
|
||||
its own cold toolchain/results. Its source-only reviews are not local passes.
|
||||
|
||||
## Verification scope
|
||||
Verified bundles for eb5161b and 4ecad78 are retained in
|
||||
`.local/integration-20260910`. Local refs `codex/behavior-snapshot-eb5161b` and
|
||||
`codex/history-seed-20260910` preserve exact commits. B4 lifetime projection and
|
||||
its guide delta are not integrated yet; coordinate shared scene wiring. Review
|
||||
found insertion versus lifetime bounds, activation after branch-local Delete and
|
||||
unassigned-lane map differences that must be addressed before acceptance.
|
||||
|
||||
On this Windows host, core all-targets passed433 tests with one intentionally ignored scaling probe; that probe separately passed with unoptimized timings. Production29084050 passed focused lifecycle22 and full desktop675 tests (the22 are included in675), and the ordinary GUI build/launch. An equivalent test-helper let-chain correction subsequently passed strict workspace/all-targets Clippy and its focused regression1/1. These results predate the newly identified header-rendering correction.
|
||||
## Unresolved requirements and gates
|
||||
|
||||
- [Exact current-host logs and inputs](experiments/lifecycle-native-ytr83r-20260911/).
|
||||
- [Test-only Clippy correction](lifecycle-clippy-correction-ytr83r-20260911.md).
|
||||
- [Current-host toolchain setup](release-host-bootstrap-ytr83r-20260911.md).
|
||||
- [Independent Windows gap review](release-windows-gap-review-ytr83r-20260911.md).
|
||||
1. Finish native basic supplementary move/camera/focus/minimum matrix and current
|
||||
scenario/process/state tasks. Main basic/transition/history/reopen passed at
|
||||
wide size. Check raw drafts, recovery, exact source and real task completion.
|
||||
2. Implement/review/natively verify contextual
|
||||
missing-source and first-action flows. Named creation/history/reopen passed;
|
||||
operand-editing and qualified Action allocation remain separate open work.
|
||||
3. Constraints/calculations and quantity compatibility are currently descriptive
|
||||
metadata; same-project includes are not maintained cross-project libraries.
|
||||
Independent semantic designs and falsification are active before broad schema
|
||||
changes. See [library review brief](release-library-candidate-review-20260910.md).
|
||||
Other-host proposals expose cosmetic package revision/type compatibility,
|
||||
subjectless analysis closure and text-only instance normalization decisions;
|
||||
full reports and paired criticism are now available from verified c6d296a.
|
||||
Its 218-assertion maintenance experiment supports explicit baseline values
|
||||
with one-level Case overrides; complete freeze and effective-case evaluation
|
||||
are required. Refined baseline/library contracts remain active externally.
|
||||
4. Implement and evaluate the source-only CLI baseline, then compare language
|
||||
improvements using both local usability and complete other-host proposals.
|
||||
Current grammar+tooling, domain syntax, namespaces and portable identity
|
||||
modules remain distinct candidates. Concise examples/CLI alone do not prove
|
||||
uncluttered modular engineering authoring or safe large-model changes.
|
||||
5. Run coherent current desktop/core/server, specification, examples, REST and
|
||||
optimized packaging gates after integration. Prior passes remain scoped.
|
||||
6. Current Linux runtime requires a viable environment: supported WSL2 import
|
||||
failed once with HCS_E_HYPERV_NOT_INSTALLED; no distro registered. The user
|
||||
has a pending environment question. No repeated denied QEMU workaround.
|
||||
7. Fresh independent cross-disciplinary release review remains required after
|
||||
actual workflow and semantic acceptance, with all material findings resolved.
|
||||
|
||||
The closed 321897f Windows gate passed 704 desktop tests with zero failed/ignored/filtered; all 261 recorded build inputs remained unchanged. Matching server `ABA87A5F…` passed semantic HTTP checks (204 schemas, 19 values, 9 rejected invalid shapes) and the corrected REST probe's 25 requests. [Closed runtime evidence](experiments/windows-runtime-321897f-20260911/README.md) preserves both earlier source-version failures. [Earlier integration checkpoint evidence](experiments/contextual-lifecycle-integration-ytr83r-20260911/README.md) preserves server/CLI 68 across 7 targets, schema 72/204 after the missing-jsonschema correction, and the old GUI's wide-name passive Save comparison.
|
||||
## Handoff and publication
|
||||
|
||||
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.
|
||||
Use Gitea issue #1 and current Wiki to coordinate as **Codex-9e8r6t**. Read before
|
||||
resuming; preserve the other host's work. Direct Git push now fails authentication;
|
||||
anonymous fetch and Gitea MCP still work. Integration3978bbe was published and
|
||||
independently fetched/hash-verified as a Git bundle on
|
||||
`codex/integration-transfer-20260910`; Wiki `Integration3978bbe` and issue#97 own
|
||||
exact transfer instructions. Other host has integrated it and an equivalent
|
||||
Sensor fix. Creation4bb4aaf was likewise published on
|
||||
`codex/integration-transfer-20260911`, file
|
||||
`harness/transfers/creation-4bb4aaf.bundle.b64`: decoded105022 bytes,
|
||||
SHA256 `0DEFAE91A56430076C432758F923BC0675911DA9FA7588C9961FF15F47EAEBF6`,
|
||||
prerequisite3978bbe. Anonymous Git readback, strict base64 length/hash check and
|
||||
`git bundle verify` passed; issue#128 gives selective integration instructions.
|
||||
Publish subsequent corrected oracle/native/default evidence similarly; preserve
|
||||
the other host's later B4/reference chooser/design work and avoid duplicates.
|
||||
|
||||
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.
|
||||
Other-host c6d296a transfer is verified and imported as local
|
||||
`codex/behavior-snapshot-c6d296a`: 334830 bytes,
|
||||
SHA256 `526A80E2D32ED0E57A7B2372B5F02D4652E8751DFEBA81953C4F045225D6AACB`,
|
||||
prerequisite1797516, from `codex/behavior-transfer-20260910` file
|
||||
`harness/transfers/behavior-c6d296a.bundle.b64`. Selected analysis/library/text
|
||||
reports, criticism, experiments and independent scenario oracle are restored;
|
||||
51 evidence files compare byte-for-byte with Git blobs. No source/ledger merge.
|
||||
Issue#136/#143 and refreshed Wiki Authoring20260911 record the handoff.
|
||||
|
||||
## 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).
|
||||
|
||||
The earlier capture failures and pending Open dialog are historical: later confirmed OA and SA reopen/Save actions are preserved in the [continuation archive](experiments/native-child-continuation-865483b-20260911/README.md). Its first OA `reopened-1948` receipts remain data-only evidence; they are not promoted to successful-open observations. Contextual-source workflows, matching new-canvas pan/zoom/clipping/focus behavior and complete combined native/package acceptance remain unfinished. The BF9 Home page, scenario guide and bundled water-pressure example opened; one normal Alt+F4 then completed, with the process absent and empty stdout/stderr. The [closed Home/Guide/normal-close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves these bounded old865/BF9 observations and complete package readback. No application exit code was captured; no exit0 or new-source native pass is inferred.
|
||||
|
||||
The selected chooser/canvas union now has a57 source and Windows gate evidence; broader persistent graphical manipulation and separate text-navigation contributions retain their own integration/acceptance boundaries. Calculations, quantities/units, maintained libraries, practical large-model textual workflows, complete OA↔SA engineering parity, documentation and fresh cross-disciplinary review remain full-goal requirements. See the [analysis proposal](release-analysis-semantics-design-20260910.md), [library proposal](release-libraries-semantics-design-20260910.md), [text-authoring design](release-textual-authoring-design-20260911.md) and [decisions](DECISIONS.md) for attributed design evidence, not completed implementation claims.
|
||||
|
||||
## Platform and published deliverables
|
||||
|
||||
**Linux setup, downloads, builds and checks are paused by explicit user instruction until the user reports networking resolved and instructs resumption.** Ubuntu26.04/WSL2 shell execution was observed; guest networking remained unavailable. Offline preparation was a workaround, not a network fix or Linux acceptance. Host VPN settings were not inspected or changed. [WSL evidence](release-wsl-postboot-ytr83r-20260911.md) and [preserved offline inputs](release-linux-offline-preparation-ytr83r-20260911.md) retain exact outcomes.
|
||||
|
||||
Peer Codex-9e8r6t published the separately verified [Windows contextual prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.contextual.20260911), source `b2c3e540`; [its publication handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-406) binds its artifacts and original-host checks. It is not a B4 release from this branch.
|
||||
|
||||
## Record navigation
|
||||
|
||||
The [one-time record migration](history/2026-09-11-ytr83r-record-migration/README.md) preserves the exact prior STATE ledger and marks PLAN/TASK_LEDGER superseded. [Earlier integration history](history/2026-09-11-ytr83r-integration/README.md) preserves source-attributed older results. Historical leases, failed approaches and next-step wording do not override current Gitea coordination or the Linux pause.
|
||||
The old ledgers, including all historical platform and native evidence, were
|
||||
archived byte-for-byte before this consolidation in
|
||||
[history/2026-09-10-ledgers-before-second-consolidation](history/2026-09-10-ledgers-before-second-consolidation/STATE.md),
|
||||
with hashes.json. Their historical-current wording does not supersede this file.
|
||||
|
||||
+109
-126
@@ -1,145 +1,128 @@
|
||||
> Superseded on 2026-09-11. This document is a preserved historical checkpoint.
|
||||
> Live plans, priorities, ownership and handoffs now reside in [Gitea issue 1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-410) and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
|
||||
> Do not resume synchronizing this ledger. See [STATE.md](STATE.md) for the integrated checkpoint.
|
||||
|
||||
# Task Ledger — first release
|
||||
|
||||
Updated 2026-09-11 for Codex-ytr83r. The full
|
||||
[user goal](../PROJECT_GOAL.md) and [current progress](STATE.md) remain authoritative.
|
||||
[Exact host ledger archives](history/2026-09-11-ytr83r-integration/README.md)
|
||||
preserve prior facts, dates and reasoning without transferring their acceptance.
|
||||
Updated 2026-09-11 by Codex-9e8r6t `/root`. Release not accepted.
|
||||
|
||||
## Goal and given requirements
|
||||
## Goal and success criteria
|
||||
|
||||
Deliver a complete, usable OA/SA release on Windows and Linux, with one explicit
|
||||
reST semantic contract and equivalent graphical, textual and REST access. Include
|
||||
constraints/calculations, quantities/units, maintained libraries, behavior/state,
|
||||
traceability and navigable offline help. Reduce cognitive load and visual clutter;
|
||||
small and large models must be practical using text alone. Later Arcadia layers
|
||||
remain future scope. Compilation or a pleasant happy path is not completion.
|
||||
Issue2 further requires consistent direct process/scenario canvas interaction;
|
||||
the owner's clarification allows domain constraints rather than arbitrary movement.
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md), copied from attachment
|
||||
`815ff7b9-73c4-41a2-a809-635ecf8389d6/goal-objective.md`, is authoritative.
|
||||
Deliver a usable, substantially complete OA/SA systems modeling product in
|
||||
Rust/Iced on Windows and Linux, with equivalent graphical/textual/REST editing,
|
||||
strict reST semantics/API, arbitrary development-artifact traceability and
|
||||
navigable offline help. Constraints/calculations, quantities/units and reusable
|
||||
model libraries and complete uncluttered text-only small/large modeling are
|
||||
explicit requirements. Later Arcadia layers remain future
|
||||
releases. Completion requires real engineering tasks, sound semantics, relevant
|
||||
passing checks and fresh independent cross-disciplinary review.
|
||||
|
||||
At15:28UTC on2026-09-11, the user explicitly deferred Linux builds and checking
|
||||
until they resolve WSL2 networking and instruct resumption. Linux setup/downloads
|
||||
are paused with inputs preserved. Continue independent Windows work; do not treat
|
||||
offline preparation as a networking fix or Linux acceptance.
|
||||
## Given facts
|
||||
|
||||
- User prioritizes Capella's domain capability and useful visual nesting while
|
||||
rejecting clutter, intrusive creation rows, static behavior diagrams and long
|
||||
homogeneous option lists. Reduce cognitive load in small and large models.
|
||||
- Source must be understandable to engineers and AI, work with Git and preserve
|
||||
full parity. OA/SA transitions in both directions must be clear and reviewed.
|
||||
- User authorizes autonomous reversible work, research, native Computer Use and
|
||||
parallel agents. Use GPT-6 Astra exclusively with task-appropriate effort.
|
||||
- Gitea coordination and shared Wiki updates are required. Identify host and
|
||||
worker consistently and preserve overlapping work. Explicit project messages
|
||||
are authorized; unrelated external messages are not.
|
||||
|
||||
## Verified facts
|
||||
|
||||
- Local start was cleanff615d0. Root decoded/hash-verified/imported published3978,
|
||||
c6 and4bb bundles; merged c6 as c81e285 and integrated creation4bb at e207b04. There were
|
||||
no production cherry-pick conflicts; docs/attributes require evidence union.
|
||||
The current goal is copied from the user's latest file.
|
||||
- Read-only integration comparison finds equivalent workspace/Sensor code in c6;
|
||||
replaying3978 or d114 is unnecessary. Core source is unchanged betweenff andc6.
|
||||
B4 now has a production Scene bridge and independently reviewed source, with
|
||||
focused lifecycle22/22 and full desktop675/675 passed on source2908405. The
|
||||
ordinary GUI build and real OA/SA lifecycle matrices now ran; OA full name-only
|
||||
Save/Undo/Redo/reopen and the corresponding SA workflow passed exact independent
|
||||
comparisons. Corrected workspace/all-targets strict Clippy also passed0;
|
||||
Unicode appearance remains open. See the
|
||||
[native record](release-lifecycle-native-ytr83r-20260911.md) for exact scope.
|
||||
- Imported source implements named behavior creation, exact prepared-batch
|
||||
correlation, success-only selection, raw-draft guards, recoverable original
|
||||
layer and modal-priority Escape. These are source facts; combined local runtime
|
||||
acceptance is pending.
|
||||
- Earlier-host desktop642 and653 suites, scoped native scenarios/creation,
|
||||
packaging25 notices and source experiments retain exact attribution in
|
||||
[STATE.md](STATE.md). They are not new local passes.
|
||||
- The original transferred creation oracle incorrectly equates physical disk files
|
||||
with reachable sources during Undo. Later external v2 corrects the evaluator
|
||||
with explicit origin/retained-byte rules; exact v2/evidence2ec was imported at
|
||||
b853a1e after complete path/hash preflight. New host execution is still pending.
|
||||
- Isolated Rust1.98.1/LLVM23.1.1 and locked dependencies are available. Packaging
|
||||
11/11 and synthetic Windows notice checks pass. Current core all-targets passed
|
||||
433 tests; its optional inheritance probe separately passed with slow debug
|
||||
timings that require controlled optimized interpretation. Focused lifecycle
|
||||
passed22/22 after two installed-Iced API corrections; full desktop passed675/675.
|
||||
Strict reST/HTML generation passes with Docutils0.22.2.
|
||||
- B4 independent criticism found and resolved self-caption overlap and two dense
|
||||
minimum-Fit hit-selection counterexamples. Shared nearest marker/message-stroke
|
||||
arbitration preserves exact marker/header/label selection. Ten real-Scene tests
|
||||
and two clip geometry tests passed in the22-test focused gate. The independent twelve-scenario native oracle passed compile,
|
||||
admission, self-check and exact baseline/name-only predictions before GUI output.
|
||||
- The separate baseline-context experiment passed218 assertions; explicit
|
||||
one-level overrides reduce repeated stable assumptions while complete frozen
|
||||
definition environments remain necessary. Proposed syntax is not source7.
|
||||
- After the user's virtualization/reboot change, supported Ubuntu26.04 install
|
||||
completed exit0 and a real x86_64 WSL2 kernel shell completed. Later VM startups
|
||||
timed out before apt. Owner328's exact VirtioProxy configuration now supports
|
||||
a persistent root shell, but requested IP/DNS/HTTPS checks failed. Signed offline
|
||||
dependency preparation advances without host VPN inspection or changes. This
|
||||
establishes guest execution, not Linux builds or GUI acceptance; exact outcomes are
|
||||
in the [WSL record](release-wsl-postboot-ytr83r-20260911.md).
|
||||
[STATE.md](STATE.md) records exact commits, live leases and scoped verification.
|
||||
The current model has one typed core, validated atomic commands and coordinated
|
||||
Project/SourceBundle history shared by desktop and REST. Included files share
|
||||
one global namespace. Current Constraint expressions are strings; numeric unit
|
||||
strings do not convert values or establish dimensional compatibility. There is
|
||||
no package/version/instance/update contract for cross-project libraries.
|
||||
|
||||
## Assumptions and unresolved questions
|
||||
Candidate3 completed the main basic/transition/history/reopen story with exact
|
||||
four/six-source independent model oracles; supplementary native cases remain.
|
||||
Corrected immutable desktop631/631 passed; later Sensor correction passed53/53.
|
||||
Final creation21/guide3 and matching full desktop653/653 passed after ancestry
|
||||
label correction. StrictClippy/fmt pass after one behavior-equivalent history
|
||||
let-chain rewrite; affected history63 and matching GUI build pass. Native named
|
||||
creation passes complete model and 7/8/9-source oracles, individual Undo/Redo and
|
||||
byte-exact nine-file reopen at actual minimum size. Cancellation preserves the
|
||||
six-file baseline. Blank process/scenario canvases provide no useful first-action
|
||||
prompt; this is an observed usability finding. SA draft recovery remains active. Mounted
|
||||
layout tests establish pane geometry, not native task acceptance. Packaging checks
|
||||
and independent review are scoped to supplied binaries/fixtures, not release
|
||||
acceptance. Supported WSL2 import failed; a current Linux runtime is unavailable.
|
||||
|
||||
- Combining already coherent desktop slices should preserve behavior; fresh build,
|
||||
automated and native gates must test that assumption.
|
||||
- The baseline/library designs can fit existing atomic Project/Command/source
|
||||
boundaries; exact references, deletion, transition, imports, freezing and
|
||||
numeric rules still need ratified contracts and falsification.
|
||||
- Pinned package provenance must be distinguished from checked type compatibility;
|
||||
unchanged quantity kinds cannot become incompatible merely through cosmetic
|
||||
repackaging without a deliberate policy. Energy and Torque remain distinct.
|
||||
- Ubuntu26.04/WSL2 is the current supported Linux route; reliable repeat startup,
|
||||
guest toolchain/dependencies and GUI/service/package execution remain open.
|
||||
Initial shell success does not erase later startup timeouts. Old QEMU work is
|
||||
retained as history, not the next route to repeat.
|
||||
- Large-model human/AI task usability, cross-file refactoring, actual chooser
|
||||
latency/keyboard/minimum geometry and complete native lifecycle behavior remain
|
||||
to be measured. Parser admission or a widget experiment is insufficient.
|
||||
Current grammar supports omitted initial IDs/defaults, lexical ownership, forward
|
||||
references and preserved source. Static audit found no exposed project-wide
|
||||
reference navigation/alias refactor; current Find searches the active buffer.
|
||||
The process example has128lines/14UUIDs/31empty-default lines, while meaningful
|
||||
shorter source is possible. Two concise snippets passed26 real REST requests,
|
||||
including identity-only normalization, exact name-only edits, rejected writes and
|
||||
restart persistence. Counts and this small trial prove neither improved native
|
||||
usability nor large-project performance.
|
||||
|
||||
## Candidate designs and selected directions
|
||||
## Assumptions
|
||||
|
||||
The imported [analysis proposal](release-analysis-semantics-design-20260910.md),
|
||||
[library proposal](release-libraries-semantics-design-20260910.md),
|
||||
[paired critique](release-semantic-proposals-review-20260910.md) and
|
||||
[textual authoring design](release-textual-authoring-design-20260911.md) retain
|
||||
alternatives and counterexamples. Metadata-only analysis and bare source includes
|
||||
do not meet evaluated quantities or maintained libraries.
|
||||
- Contextual named creation, remaining-height canvas and local source insertion
|
||||
can reduce actual authoring detours. Native engineer tasks must establish this.
|
||||
- A bounded pure calculation/quantity model and pinned library content may fit
|
||||
the existing typed core better than a general-purpose metamodel rewrite.
|
||||
Independent designs and experiments must test this before ratification.
|
||||
- Existing OA/SA semantics provide a useful foundation; neither their presence
|
||||
nor old acceptance proves full conformance to the updated release objective.
|
||||
|
||||
Codex-956atf root selected property-owned baseline values/formulas with explicit
|
||||
ModelBaseline/Case evaluation and one-level case overrides for contract refinement.
|
||||
No hidden default case or UI-dependent evaluation; inherited formulas use the
|
||||
effective requested case, and complete freeze captures definitions/provenance.
|
||||
Exact numeric, library normalization/type compatibility and transition details
|
||||
remain open. This direction is recorded in [DECISIONS.md](DECISIONS.md), not silently
|
||||
treated as completed production semantics.
|
||||
## Open questions
|
||||
|
||||
The external staged prerequisite design reserves source/consumer identities,
|
||||
keeps the parent context/rawtext/slot, stages locally and creates source+consumer
|
||||
in one atomic final command. It must not implicitly allocate work, involve
|
||||
capabilities, retarget an Exchange-fixed endpoint or accept a different editor.
|
||||
Qualified Action allocation remains a distinct engineering choice.
|
||||
- Which precise calculation, quantity-kind/unit and library contracts satisfy
|
||||
practical budgets, reuse and update workflows without ambiguous defaults or
|
||||
implicit execution? How should they migrate and participate in transitions?
|
||||
- Which behavior workflows still force cancellation/navigation to create missing
|
||||
context, and which local actions best remove those detours?
|
||||
- Which text-only assistance best preserves engineering context and identity:
|
||||
native language services, external-editor services, source recipes or explicit
|
||||
reviewed patch operations? Which grammar changes are actually necessary?
|
||||
- Does the new scenario pane remain readable and editable through actual native
|
||||
window sizes, camera changes, nested fragments and reopened caller contexts?
|
||||
- Which viable Linux environment can run current GUI/server acceptance? Current
|
||||
VM limitation does not block remaining Windows, semantics or source work.
|
||||
|
||||
## Retained implementation decisions
|
||||
## Candidate designs and decisions
|
||||
|
||||
- Keep one authoritative typed Project, validated atomic Commands, stable
|
||||
identities and explicit references. Semantic ownership/allocation/order and
|
||||
diagram geometry remain distinct; forms are adapters, not a second metamodel.
|
||||
- Preserve source ownership/comments/includes and exact model/source history.
|
||||
Writeback succeeds before adoption. Invalid/rejected raw drafts survive and
|
||||
browsing/tool selection does not silently accept another editor. Recovery
|
||||
validates/replans rather than trusting serialized commands.
|
||||
- Semantic reconciliation uses finite typed facets, frozen baselines, explicit
|
||||
mappings and one atomic candidate. Names alone never establish identity.
|
||||
- OA↔SA transitions are explicit engineering decisions. New persistent semantics
|
||||
need validation/reference/deletion/transition/source/API/UI/history parity.
|
||||
- Exact evidence bytes and host attribution survive integration. No focused
|
||||
test total is added to a full-suite count; old binary acceptance does not
|
||||
transfer to newly merged code.
|
||||
- Behavior creation: compact New → three task choices → focused name/owner/
|
||||
viewpoint card, explicit missing inputs, atomic creation and reserved recovery
|
||||
IDs. Implemented, reviewed and scoped native creation/history/reopen passed.
|
||||
- Contextual prerequisite creation: stage an explicit typed source inside the
|
||||
current parent draft, then validate/adopt source and consumer atomically. This
|
||||
preserves intent and one-step history; a separate Architecture excursion or
|
||||
early source commit loses those guarantees. Initial Lifeline/process Work/
|
||||
machine-owner adapters and semantic first-action prompts are being implemented
|
||||
and independently challenged; acceptance remains pending. See the
|
||||
[bounded plan](release-contextual-source-creation-plan-20260911.md).
|
||||
- Scenario workspace: compact header, optional Tools/Selection shelves, actual
|
||||
remaining-height canvas/form, pinned actions, independent scrolling and
|
||||
explicit Fit/Focus. Mounted tests support this choice; native proof pending.
|
||||
- Quantities/calculations: compare additive pure analysis records, extension of
|
||||
existing fields and external evaluation. No broad schema decision yet.
|
||||
- Libraries: compare copied source, live references, generic definition/usages
|
||||
and pinned typed packages with explicit mappings and reviewed updates.
|
||||
[Falsification brief](release-library-candidate-review-20260910.md) records
|
||||
identity, dependency, offline and conflict requirements. No ratified winner.
|
||||
- Text authoring: use existing omission/default/identity contracts for concise
|
||||
experiments; compare source navigation/refactoring/patch assistance before
|
||||
grammar redesign. No namespace, hidden identity or omission change ratified.
|
||||
|
||||
## Current strategy and coordination
|
||||
Long-lived decisions remain in [DECISIONS.md](DECISIONS.md). New user scope and
|
||||
normative contracts supersede older prototype exclusions.
|
||||
|
||||
Root owns local integration, serialized builds, native inputs and final synthesis.
|
||||
The local audit resolves evidence and reviews source; independent server review
|
||||
has its own report. Codex-9e8r6t owns staged sources/first actions and source CLI;
|
||||
Codex-956atf owns chooser, semantic contracts and the shared canvas design. Read Gitea issues #1/#2 and Wiki before
|
||||
resuming, obtain exact coherent transfer heads, and respect their source ownership.
|
||||
## Current strategy
|
||||
|
||||
First finish and verify the integrated current source using the imported corrected
|
||||
evaluator and newly prepared independent lifetime oracle. Then close remaining modeling/textual,
|
||||
analysis/library and platform gates with independent criticism and whole-task
|
||||
acceptance. Update facts, assumptions and decisions as evidence changes them.
|
||||
Finish coherent creation gates and test the rebuilt product against independent
|
||||
saved-source oracles. Close remaining scenario lifecycle integration boundaries.
|
||||
Use the native Capella study and independent criticism to address real authoring
|
||||
friction. In parallel, resolve foundational semantic additions and text-only
|
||||
authoring through bounded competing designs and executable experiments. Then implement all representations,
|
||||
verify real REST and Windows/Linux workflows, build actual optimized packages
|
||||
and conduct fresh end-state review. Do not weaken acceptance to match current
|
||||
implementation or stop because a narrow technical milestone passed.
|
||||
|
||||
Pre-consolidation content is preserved byte-for-byte in
|
||||
[the dated archive](history/2026-09-10-ledgers-before-second-consolidation/TASK_LEDGER.md).
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Native issue4 fixture/oracle. generate DIR | check oa|sa STAGE ENTRY
|
||||
//! snapshot ENTRY DIR | compare ENTRY DIR | self-check
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{collections::{BTreeMap, BTreeSet}, fs, path::Path};
|
||||
fn id(n: u128) -> Id { Id::from_u128(0xc1140000000040008000000000000000 + n) }
|
||||
fn work(layer: Layer) -> Kind { if layer == Layer::Oa { Kind::Activity } else { Kind::Function } }
|
||||
fn fixture(layer: Layer) -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.id = id(1); p.alias = "child_work_native".into(); p.name = format!("Issue4 {layer:?} child work");
|
||||
for (n, kind, name) in [(10, if layer == Layer::Oa { Kind::Entity } else { Kind::System }, "Performer"), (11, work(layer), "Parent work")] {
|
||||
p.elements.insert(id(n), Element { id: id(n), alias: format!("seed_{n}"), name: name.into(), description: format!("Preserve original {name}"), parent: None, body: ElementBody::new(kind, layer).unwrap() });
|
||||
}
|
||||
p.relations.insert(id(20), Relation { id:id(20), alias:"original_allocation".into(), name:"Original responsibility".into(), description:"Preserve allocation engineering rationale 雨".into(), body:RelationBody::Allocation { source:id(10), target:id(11) } });
|
||||
if layer == Layer::Oa {
|
||||
for n in [12,13] {
|
||||
p.elements.insert(id(n), Element { id:id(n), alias:format!("role_{n}"), name:format!("Responsibility role {n}"), description:"Preserve role".into(), parent:None, body:ElementBody::new(Kind::Role, layer).unwrap() });
|
||||
for (r, kind, source, target) in [(n+10,RelationKind::RoleAssignment,id(n),id(11)),(n+20,RelationKind::RoleRealization,id(10),id(n))] {
|
||||
p.relations.insert(id(r), Relation { id:id(r), alias:format!("responsibility_{r}"), name:format!("Role link {r}"), description:format!("Retain role link metadata {r}"), body:RelationBody::new(kind,source,target) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, owner) in [id(20),id(22),id(23)].into_iter().filter(|owner| p.relations.contains_key(owner)).enumerate() {
|
||||
let key=id(50+index as u128);
|
||||
p.artifact_links.insert(key, ArtifactLink { id:key, alias:format!("evidence_{index}"), name:format!("Responsibility evidence {index}"), description:"Keep owner identity and evidence metadata".into(), owner, relation:ArtifactRelation::References, uri:format!("https://example.invalid/evidence/{index}"), revision:Some("review-7".into()), fragment:Some("assignment".into()) });
|
||||
}
|
||||
p.diagrams.insert(id(2), Diagram { id:id(2), alias:"allocation_view".into(), name:format!("{layer:?} allocated work"), description:"Native child-work fixture".into(), layer, projection:ProjectionKind::Allocation, routes:vec![], view_options:Default::default(), nodes:BTreeMap::new() });
|
||||
for (n,element,x,y,collapsed) in [(40,id(10),60.,60.,false),(41,id(11),84.,124.,true)] {
|
||||
p.diagrams.get_mut(&id(2)).unwrap().nodes.insert(id(n), NodePlacement { id:id(n), alias:format!("seed_view_{n}"), element, position:Position{x,y}, size:Size{width:224.,height:94.}, collapsed, port_anchors:vec![] });
|
||||
}
|
||||
execute_batch(&p,&[]).unwrap()
|
||||
}
|
||||
fn bundle(p:&Project)->CompiledSourceProject {
|
||||
let source=language::format(p).replacen("{\n","{\n include \"rationale.arcadia\";\n",1);
|
||||
let raw=SourceBundle { entry:"project.arcadia".into(), files:BTreeMap::from([("project.arcadia".into(),source),("rationale.arcadia".into(),"// Preserve included note bytes: responsibility 雨.\r\n".into())]) };
|
||||
let result=compile_bundle(&raw,ProjectLimits::default()).unwrap();
|
||||
assert_eq!(result.bundle,raw); assert_eq!(&result.decoded.project,p); result
|
||||
}
|
||||
fn load(entry:&Path)->CompiledSourceProject {
|
||||
let raw=source_files::load_project(entry,ProjectLimits::default()).unwrap().reachable_bundle();
|
||||
let result=compile_bundle(&raw,ProjectLimits::default()).unwrap();
|
||||
assert_eq!(result.bundle,raw,"No source normalization permitted");
|
||||
execute_batch(&result.decoded.project,&[]).unwrap(); result
|
||||
}
|
||||
fn advance(base:&mut CompiledSourceProject,commands:Vec<Command>) {
|
||||
let expected=execute_batch(&base.decoded.project,&commands).unwrap();
|
||||
*base=update_bundle(base,&expected,ProjectLimits::default()).unwrap();
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Keys { children:[Id;3], nodes:[Id;3], allocation:Id }
|
||||
fn expected(layer:Layer,stage:usize,k:Keys)->CompiledSourceProject {
|
||||
assert!(stage<=3);
|
||||
let mut base=bundle(&fixture(layer));
|
||||
for step in 0..stage {
|
||||
let p=&base.decoded.project;
|
||||
let key=k.children[step];
|
||||
let name=format!("New {}",work(layer).label().to_lowercase());
|
||||
let element=Element { id:key, alias:p.fresh_alias(&name,"element",key), name, description:String::new(), parent:Some(if step==2 { k.children[0] } else { id(11) }), body:ElementBody::new(work(layer),layer).unwrap() };
|
||||
let mut commands=vec![Command::CreateElement(element.clone())];
|
||||
if step==1 {
|
||||
commands.push(Command::CreateRelation(Relation { id:k.allocation, alias:p.fresh_alias("Performs","relation",k.allocation), name:"Performs".into(), description:String::new(), body:RelationBody::Allocation { source:id(10),target:key } }));
|
||||
} else {
|
||||
for original in p.relations.values().filter(|r| [id(20),id(22),id(23)].contains(&r.id)) {
|
||||
let mut r=original.clone();
|
||||
match &mut r.body { RelationBody::Allocation{target,..}|RelationBody::RoleAssignment{target,..}=>*target=key,_=>unreachable!() }
|
||||
commands.push(Command::EditRelation(r.id,r));
|
||||
}
|
||||
}
|
||||
if step==0 {
|
||||
let mut node=p.diagrams[&id(2)].nodes[&id(41)].clone(); node.collapsed=false;
|
||||
commands.push(Command::EditNode{diagram:id(2),original:node.id,node});
|
||||
}
|
||||
let mut with_element=p.clone(); with_element.elements.insert(key,element);
|
||||
let (x,y)=[(108.,188.),(108.,298.),(132.,252.)][step];
|
||||
commands.push(Command::CreateNode { diagram:id(2), node:NodePlacement { id:k.nodes[step], alias:with_element.fresh_alias(&format!("view_{}",with_element.named(key)),"node",k.nodes[step]), element:key,position:Position{x,y},size:Size{width:224.,height:94.},collapsed:false,port_anchors:vec![] } });
|
||||
if step==2 {
|
||||
// Grandchild expands first child to bottom370; sibling follows at394
|
||||
// (24px reflow gap), independently predicted for this fixed fixture.
|
||||
let mut sibling=p.diagrams[&id(2)].nodes[&k.nodes[1]].clone(); sibling.position.y=394.;
|
||||
commands.push(Command::EditNode{diagram:id(2),original:sibling.id,node:sibling});
|
||||
}
|
||||
advance(&mut base,commands);
|
||||
}
|
||||
base
|
||||
}
|
||||
fn only(ids:Vec<Id>,role:&str)->Id { assert_eq!(ids.len(),1,"Exactly one {role}"); ids[0] }
|
||||
fn admit(actual:&Project,layer:Layer,stage:usize)->Keys {
|
||||
let mut k=Keys{children:[Id::nil();3],nodes:[Id::nil();3],allocation:Id::nil()};
|
||||
if stage==0 { return k; }
|
||||
let target=actual.relations[&id(20)].target();
|
||||
k.children[0]=if stage==3 { actual.elements[&target].parent.unwrap() } else { target };
|
||||
if stage>=2 {
|
||||
k.children[1]=only(actual.elements.values().filter(|e|e.parent==Some(id(11))&&e.id!=k.children[0]&&e.kind()==work(layer)).map(|e|e.id).collect(),"second child");
|
||||
k.allocation=only(actual.relations.values().filter(|r|r.kind()==RelationKind::Allocation&&r.source()==id(10)&&r.target()==k.children[1]).map(|r|r.id).collect(),"new sibling allocation");
|
||||
}
|
||||
if stage==3 { k.children[2]=target; }
|
||||
for step in 0..stage {
|
||||
let e=&actual.elements[&k.children[step]];
|
||||
assert_eq!(e.kind(),work(layer)); assert_eq!(e.layer(),layer);
|
||||
assert_eq!(e.parent,Some(if step==2{k.children[0]}else{id(11)}));
|
||||
k.nodes[step]=only(actual.diagrams[&id(2)].nodes.values().filter(|n|n.element==e.id).map(|n|n.id).collect(),"child placement");
|
||||
}
|
||||
let seed=fixture(layer); let mut used=BTreeSet::new();
|
||||
for key in k.children[..stage].iter().chain(&k.nodes[..stage]).copied().chain((stage>=2).then_some(k.allocation)) {
|
||||
assert_eq!(key.get_version_num(),4,"Native fresh UUIDv4 required");
|
||||
assert!(!seed.contains(key)&&used.insert(key),"Fresh distinct role identity required");
|
||||
}
|
||||
k
|
||||
}
|
||||
fn check(actual:&CompiledSourceProject,layer:Layer,stage:usize) {
|
||||
let prediction=expected(layer,stage,admit(&actual.decoded.project,layer,stage));
|
||||
assert_eq!(actual.decoded.project,prediction.decoded.project,"Complete typed model mismatch, including placements and references");
|
||||
assert_eq!(actual.bundle,prediction.bundle,"Exact source mismatch, including included note bytes");
|
||||
}
|
||||
fn history(actual:&CompiledSourceProject,layer:Layer,stage:usize,first:&CompiledSourceProject,second:Option<&CompiledSourceProject>) {
|
||||
check(first,layer,1);
|
||||
let first_keys=admit(&first.decoded.project,layer,1);
|
||||
let current_keys=admit(&actual.decoded.project,layer,stage);
|
||||
assert_eq!(current_keys.children[0],first_keys.children[0],"First child identity must match original first-commit carrier");
|
||||
assert_eq!(current_keys.nodes[0],first_keys.nodes[0],"First placement identity must match original first-commit carrier");
|
||||
if stage==3 {
|
||||
let second=second.expect("Original second-commit carrier required");
|
||||
check(second,layer,2);
|
||||
history(second,layer,2,first,None);
|
||||
let second_keys=admit(&second.decoded.project,layer,2);
|
||||
assert_eq!(current_keys.children[1],second_keys.children[1],"Sibling identity must persist");
|
||||
assert_eq!(current_keys.nodes[1],second_keys.nodes[1],"Sibling placement identity must persist");
|
||||
assert_eq!(current_keys.allocation,second_keys.allocation,"Sibling allocation identity must persist");
|
||||
}
|
||||
}
|
||||
fn write_bundle(dir:&Path,raw:&SourceBundle) {
|
||||
assert!(!dir.exists(),"Use a new evidence directory"); fs::create_dir_all(dir).unwrap();
|
||||
for (name,bytes) in &raw.files { let path=dir.join(name); if let Some(parent)=path.parent(){fs::create_dir_all(parent).unwrap();} fs::write(path,bytes).unwrap(); }
|
||||
}
|
||||
fn main() {
|
||||
assert_eq!(SCHEMA_VERSION,7);
|
||||
let args:Vec<_>=std::env::args().collect();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("generate")=>{assert_eq!(args.len(),3);let dir=Path::new(&args[2]);assert!(!dir.exists()); for (name,layer) in [("oa",Layer::Oa),("sa",Layer::Sa)] {write_bundle(&dir.join(name),&bundle(&fixture(layer)).bundle);} println!("PASS deterministic OA/SA native fixtures generated");}
|
||||
Some("check")=>{
|
||||
let layer=match args[2].as_str(){"oa"=>Layer::Oa,"sa"=>Layer::Sa,_=>panic!("oa|sa")};
|
||||
let stage=args[3].parse::<usize>().unwrap();assert!(stage<=3);
|
||||
assert_eq!(args.len(),5+stage.saturating_sub(1),"Stages2/3 require original stage1/stage2 checkpoint directories");
|
||||
let actual=load(Path::new(&args[4]));check(&actual,layer,stage);
|
||||
if stage>=2 {
|
||||
let first=load(&Path::new(&args[5]).join("project.arcadia"));
|
||||
let second=if stage==3 {Some(load(&Path::new(&args[6]).join("project.arcadia")))} else {None};
|
||||
history(&actual,layer,stage,&first,second.as_ref());
|
||||
}
|
||||
println!("PASS complete model, exact source and pinned history stage {stage}");
|
||||
}
|
||||
Some("snapshot")=>{assert_eq!(args.len(),4);let actual=load(Path::new(&args[2]));write_bundle(Path::new(&args[3]),&actual.bundle);println!("PASS immutable source checkpoint");}
|
||||
Some("compare")=>{assert_eq!(args.len(),4);let actual=load(Path::new(&args[2]));let checkpoint=load(&Path::new(&args[3]).join(&actual.bundle.entry));assert_eq!(actual.bundle,checkpoint.bundle,"Saved reachable source bytes changed");assert_eq!(actual.decoded.project,checkpoint.decoded.project);println!("PASS exact checkpoint bytes and model");}
|
||||
Some("self-check")=>{for layer in [Layer::Oa,Layer::Sa]{let keys=Keys{children:std::array::from_fn(|_|Id::new_v4()),nodes:std::array::from_fn(|_|Id::new_v4()),allocation:Id::new_v4()};for stage in 0..=3 {let candidate=expected(layer,stage,keys);check(&candidate,layer,stage);if stage>=2 {history(&candidate,layer,stage,&expected(layer,1,keys),(stage==3).then(||expected(layer,2,keys)).as_ref());}let mut altered=candidate.decoded.project.clone();altered.elements.get_mut(&id(11)).unwrap().description="Unrequested mutation".into();assert_ne!(expected(layer,stage,admit(&altered,layer,stage)).decoded.project,altered);}}println!("PASS oracle fixed points and unrelated-mutation discriminator");}
|
||||
_=>panic!("generate DIR | check oa|sa STAGE ENTRY [STAGE1DIR] [STAGE2DIR] | snapshot ENTRY DIR | compare ENTRY DIR | self-check"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Authoring prerelease evidence
|
||||
|
||||
Exact preserved producer, independent reviewer and root native evidence for
|
||||
Windows preview `v0.2.0-preview.authoring.20260911`. `hashes.json` records each
|
||||
copied artifact's original private path, length and SHA256. Git must retain these
|
||||
measured bytes. The executable ZIP and validation notes are release assets.
|
||||
|
||||
The preserved Python files are the original host-specific harnesses. Their
|
||||
relative paths and source/toolchain prerequisites refer to the recorded original
|
||||
`.local` layout; they are not portable standalone tests when run from this archive.
|
||||
To reproduce them, restore that layout in a disposable checkout and use fresh
|
||||
output directories. They never contain Gitea credentials. The GUI launch script
|
||||
only launches the application; native actions were performed using Computer Use.
|
||||
|
||||
See the three `harness/release-authoring-prerelease-*-20260911.md` reports for
|
||||
methods, initial GUI harness failure, passed checks, source provenance and limits.
|
||||
`published-verified.json` records the final release readback; upload records bind
|
||||
the exact downloaded assets. Publication does not complete the wider OA/SA goal.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"pid": 10800,
|
||||
"exe": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm\\arcadia-studio.exe",
|
||||
"archive_sha256": "664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa",
|
||||
"files": 559,
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm;C:\\Windows\\System32;C:\\Windows",
|
||||
"local_appdata": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\profile\\local",
|
||||
"appdata": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\profile\\roaming"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
root = Path.cwd()
|
||||
name = 'ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm'
|
||||
archive = root / '.local/authoring-prerelease-20260911/packages' / (name + '.zip')
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == '664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa'
|
||||
trial = root / '.local/authoring-prerelease-gui-20260911-v2'
|
||||
trial.mkdir(exist_ok=False)
|
||||
extract = trial / 'extracted'
|
||||
extract.mkdir()
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
names = z.namelist()
|
||||
assert len(names) == len(set(n.casefold() for n in names)) == 559
|
||||
assert all(n.startswith(name + '/') and '..' not in Path(n).parts for n in names)
|
||||
z.extractall(extract)
|
||||
package = extract / name
|
||||
for line in (package / 'SHA256SUMS.txt').read_text().splitlines():
|
||||
digest, path = line.split(' ', 1)
|
||||
assert hashlib.sha256((package / path).read_bytes()).hexdigest() == digest, path
|
||||
for folder in ['profile/local', 'profile/roaming']:
|
||||
(trial / folder).mkdir(parents=True)
|
||||
env = os.environ.copy()
|
||||
env['LOCALAPPDATA'] = str(trial / 'profile/local')
|
||||
env['APPDATA'] = str(trial / 'profile/roaming')
|
||||
env['PATH'] = os.pathsep.join([str(package), str(Path(env['SYSTEMROOT']) / 'System32'), env['SYSTEMROOT']])
|
||||
exe = package / 'arcadia-studio.exe'
|
||||
assert hashlib.sha256(exe.read_bytes()).hexdigest() == 'd4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31'
|
||||
with (trial / 'stdout.log').open('wb') as out, (trial / 'stderr.log').open('wb') as err:
|
||||
process = subprocess.Popen([str(exe)], cwd=package, env=env, stdout=out, stderr=err)
|
||||
result = {'pid': process.pid, 'exe': str(exe), 'archive_sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), 'files': len(names), 'path': env['PATH'], 'local_appdata': env['LOCALAPPDATA'], 'appdata': env['APPDATA']}
|
||||
(trial / 'launch.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm\\arcadia-studio.exe",
|
||||
"C:\\Windows\\SYSTEM32\\ntdll.dll",
|
||||
"C:\\Windows\\System32\\KERNEL32.DLL",
|
||||
"C:\\Windows\\System32\\KERNELBASE.dll",
|
||||
"C:\\Windows\\SYSTEM32\\apphelp.dll",
|
||||
"C:\\Windows\\System32\\user32.dll",
|
||||
"C:\\Windows\\System32\\win32u.dll",
|
||||
"C:\\Windows\\System32\\GDI32.dll",
|
||||
"C:\\Windows\\System32\\gdi32full.dll",
|
||||
"C:\\Windows\\System32\\msvcp_win.dll",
|
||||
"C:\\Windows\\System32\\ucrtbase.dll",
|
||||
"C:\\Windows\\System32\\ole32.dll",
|
||||
"C:\\Windows\\System32\\combase.dll",
|
||||
"C:\\Windows\\System32\\RPCRT4.dll",
|
||||
"C:\\Windows\\System32\\advapi32.dll",
|
||||
"C:\\Windows\\System32\\msvcrt.dll",
|
||||
"C:\\Windows\\System32\\sechost.dll",
|
||||
"C:\\Windows\\System32\\bcryptprimitives.dll",
|
||||
"C:\\Windows\\System32\\shell32.dll",
|
||||
"C:\\Windows\\System32\\imm32.dll",
|
||||
"C:\\Windows\\System32\\ws2_32.dll",
|
||||
"C:\\Windows\\System32\\oleaut32.dll",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm\\libunwind.dll",
|
||||
"C:\\Windows\\SYSTEM32\\bcrypt.dll",
|
||||
"C:\\Windows\\SYSTEM32\\dwmapi.dll",
|
||||
"C:\\Windows\\SYSTEM32\\uxtheme.dll",
|
||||
"C:\\Windows\\SYSTEM32\\opengl32.dll",
|
||||
"C:\\Windows\\SYSTEM32\\GLU32.dll",
|
||||
"C:\\Windows\\SYSTEM32\\CRYPTBASE.DLL",
|
||||
"C:\\Windows\\SYSTEM32\\dxcore.dll",
|
||||
"C:\\Windows\\System32\\MSCTF.dll",
|
||||
"C:\\Windows\\SYSTEM32\\kernel.appcore.dll",
|
||||
"C:\\Windows\\System32\\clbcatq.dll",
|
||||
"C:\\Windows\\system32\\dataexchange.dll",
|
||||
"C:\\Windows\\system32\\twinapi.appcore.dll",
|
||||
"C:\\Windows\\system32\\explorerframe.dll",
|
||||
"C:\\Windows\\System32\\SHCORE.dll",
|
||||
"C:\\Windows\\System32\\SHLWAPI.dll",
|
||||
"C:\\Windows\\System32\\Windows.Storage.dll",
|
||||
"C:\\Windows\\SYSTEM32\\powrprof.dll",
|
||||
"C:\\Windows\\SYSTEM32\\UMPDC.dll",
|
||||
"C:\\Windows\\SYSTEM32\\CFGMGR32.dll",
|
||||
"C:\\Windows\\system32\\dxgi.dll",
|
||||
"C:\\Windows\\SYSTEM32\\windows.staterepositorycore.dll",
|
||||
"C:\\Windows\\SYSTEM32\\directxdatabasehelper.dll",
|
||||
"C:\\Windows\\SYSTEM32\\d3d12.dll",
|
||||
"C:\\Windows\\SYSTEM32\\d3dcompiler_47.dll",
|
||||
"C:\\Windows\\SYSTEM32\\cryptsp.dll",
|
||||
"C:\\Windows\\System32\\DriverStore\\FileRepository\\vm3d.inf_amd64_39d3dfd189710365\\vm3dgl64.dll",
|
||||
"C:\\Windows\\System32\\DriverStore\\FileRepository\\vm3d.inf_amd64_39d3dfd189710365\\vm3dglhelper64.dll",
|
||||
"C:\\Windows\\SYSTEM32\\WINMM.dll",
|
||||
"C:\\Windows\\SYSTEM32\\D3D12Core.dll",
|
||||
"C:\\Windows\\SYSTEM32\\d3d10warp.dll",
|
||||
"C:\\Windows\\SYSTEM32\\D3DSCache.dll",
|
||||
"C:\\Windows\\SYSTEM32\\USERENV.dll",
|
||||
"C:\\Windows\\SYSTEM32\\dxilconv.dll",
|
||||
"C:\\Windows\\SYSTEM32\\dcomp.dll",
|
||||
"C:\\Windows\\SYSTEM32\\Microsoft.Internal.WarpPal.dll",
|
||||
"C:\\Windows\\SYSTEM32\\textinputframework.dll",
|
||||
"C:\\Windows\\SYSTEM32\\uiautomationcore.dll",
|
||||
"C:\\Windows\\SYSTEM32\\OLEACC.dll",
|
||||
"C:\\Windows\\System32\\threadpoolwinrt.dll"
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"matched_files": 558,
|
||||
"exact_physical_inventory": 559,
|
||||
"result": "PASS after native packaged walkthrough open and Save"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
[
|
||||
{
|
||||
"path": "producer-inventory.json",
|
||||
"original": ".local/authoring-prerelease-20260911/inventory.json",
|
||||
"bytes": 66465,
|
||||
"sha256": "3c78dea4e93dae186fd24ab826c6680178a2c7849c270a22deae2ff732f1857b"
|
||||
},
|
||||
{
|
||||
"path": "producer-build-evidence.json",
|
||||
"original": ".local/authoring-prerelease-20260911/build-evidence.json",
|
||||
"bytes": 1230,
|
||||
"sha256": "ccc1ccd087f1de9605ba23a4680e1cb6d37ce5ccf0f8db03c03c4797ae32db25"
|
||||
},
|
||||
{
|
||||
"path": "producer-package-result.json",
|
||||
"original": ".local/authoring-prerelease-20260911/package-initial-result.json",
|
||||
"bytes": 216,
|
||||
"sha256": "45f372960931fa34ca75086df5d346eab9127a7ae70b53aed86751e372c24bae"
|
||||
},
|
||||
{
|
||||
"path": "producer-roundtrip.json",
|
||||
"original": ".local/authoring-prerelease-20260911/committed-roundtrip.json",
|
||||
"bytes": 40443,
|
||||
"sha256": "27e4cd2d6130a9ced76560cee130b5d363478ee0da62925e785691c63fadb098"
|
||||
},
|
||||
{
|
||||
"path": "reviewer-artifact-result.json",
|
||||
"original": ".local/authoring-prerelease-review-20260911/artifact-result.json",
|
||||
"bytes": 338784,
|
||||
"sha256": "8b20e9d294338754ddc9b3320b59f55fc19ab979b8da18819fb47a8036db0bba"
|
||||
},
|
||||
{
|
||||
"path": "reviewer-server-result.json",
|
||||
"original": ".local/authoring-prerelease-review-20260911/server-result.json",
|
||||
"bytes": 128779,
|
||||
"sha256": "086bf21e79f3bc7deddfd3278c8ade9b470b11031f7fb278ca0ead3a75b6cfb1"
|
||||
},
|
||||
{
|
||||
"path": "reviewer.py",
|
||||
"original": ".local/authoring-prerelease-review-20260911/review.py",
|
||||
"bytes": 21434,
|
||||
"sha256": "af9eb20946dfcec78bf8959f1d5dc175e82ebb9fabba9f94cefe590fea516b92"
|
||||
},
|
||||
{
|
||||
"path": "gui-launch.json",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/launch.json",
|
||||
"bytes": 827,
|
||||
"sha256": "ade93cb1abea623f1cc63b8124857cad822e4f75e8d1c389f75fd8a9089b2c39"
|
||||
},
|
||||
{
|
||||
"path": "gui-loaded-modules.json",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/loaded-modules.json",
|
||||
"bytes": 3123,
|
||||
"sha256": "ccae3d91310644e171bf35028f1c0758ef330ebb051983e0524b8cd714652b6b"
|
||||
},
|
||||
{
|
||||
"path": "gui-post-save-exact.json",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/post-save-package-exact.json",
|
||||
"bytes": 133,
|
||||
"sha256": "e340289a2c09058c539e63de33a361326b146fc181a8bd1349a88bfc813f2420"
|
||||
},
|
||||
{
|
||||
"path": "upload-binaries-verified.json",
|
||||
"original": ".local/authoring-prerelease-20260911/upload-binaries-verified.json",
|
||||
"bytes": 685,
|
||||
"sha256": "16799aa759e7287f4c50af1068d311f6f916f97e0cfbd7aaddf7432770d281f3"
|
||||
},
|
||||
{
|
||||
"path": "gui-launch.py",
|
||||
"original": ".local/authoring-prerelease-gui-20260911.py",
|
||||
"bytes": 2001,
|
||||
"sha256": "db256498e44cbb67cd84738a1a37f0e9ab0931d130a3b8c830af2c02cff0924c"
|
||||
},
|
||||
{
|
||||
"path": "home.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/home.png",
|
||||
"bytes": 97695,
|
||||
"sha256": "e9c24489e949096347b11234d8d2d343716fd5895903c4978d139107a70e3e5a"
|
||||
},
|
||||
{
|
||||
"path": "oa-walkthrough-save.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/oa-walkthrough-save.png",
|
||||
"bytes": 131037,
|
||||
"sha256": "61b2cbdd2dddb6fa176000198ae845554147cbc80abe52fa992211157f6297b5"
|
||||
},
|
||||
{
|
||||
"path": "sa-walkthrough.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/sa-walkthrough.png",
|
||||
"bytes": 132180,
|
||||
"sha256": "7f3a2bbed9fa69f9d5d844f9a52f81fbc3928244f49d7a9ff1e475c3f2411dbf"
|
||||
},
|
||||
{
|
||||
"path": "state-workspace.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/state-workspace.png",
|
||||
"bytes": 135827,
|
||||
"sha256": "79010527e5e1bb8ccf428788a00dea80e6d8253f3d7c249089218e2df9edd6da"
|
||||
},
|
||||
{
|
||||
"path": "offline-guide.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/offline-guide.png",
|
||||
"bytes": 185496,
|
||||
"sha256": "58812d2bd0d6e73daa5187f2dd87564189960387ac4bb1e399af4b8d114c05df"
|
||||
},
|
||||
{
|
||||
"path": "published-verified.json",
|
||||
"original": ".local/authoring-prerelease-20260911/published-verified.json",
|
||||
"bytes": 374,
|
||||
"sha256": "668f28c4d8cf753386085271eb181513b0127940f324f423a2b87614ec8cc85a"
|
||||
},
|
||||
{
|
||||
"path": "upload-validation-verified.json",
|
||||
"original": ".local/authoring-prerelease-20260911/upload-validation-verified.json",
|
||||
"bytes": 277,
|
||||
"sha256": "f39859baea544662fc4bdd1fe14dbcb9bdab5639b173754a34b58b853f0d47a7"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"source_identity": "Exact-byte reconstructed input snapshot 234e01e27d6935d213097b345d91d369f808af82, derived from release base 2ec72d4241fa8971fa6fe9f626937de9dfd7c7eb by seven documented LF-to-CRLF restorations and preservation attributes. All 253 GUI manifest inputs and 120 preserved server source files matched. This derived commit is not claimed as the original build commit; GUI and server original build records are retained separately.",
|
||||
"profile": "Prebuilt Windows debug/development artifacts: GUI cargo build --locked -p oa-desktop --bin arcadia-studio, September 11 2026; preserved server from September 7 workspace bins build. Not a release-profile or reproducible-build claim.",
|
||||
"compiler": "rustc 1.98.1, commit 48a229ceaefd4985c50990b14116b6d856af0985, x86_64-pc-windows-gnullvm, LLVM 22.1.8. Current rustc -vV, all 58 prior-notice target-library hashes and both embedded compiler paths matched; original recorded build evidence retained.",
|
||||
"target": "x86_64-pc-windows-gnullvm",
|
||||
"binary_sha256": {
|
||||
"arcadia-studio.exe": "d4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31",
|
||||
"arcadia-server.exe": "efeeb89c0d2e0f93df4472cb3e9f48b2b722a37ae1043c90a5a56f2e845fe665"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"started": "2026-09-11T12:17:30.7730570Z",
|
||||
"source_commit": "234e01e27d6935d213097b345d91d369f808af82",
|
||||
"success": true,
|
||||
"finished": "2026-09-11T12:21:23.0302800Z",
|
||||
"elapsed_seconds": 232.2812442
|
||||
}
|
||||
+1018
-1312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": 1,
|
||||
"tag": "v0.2.0-preview.authoring.20260911",
|
||||
"url": "http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.authoring.20260911",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"asset_count": 3,
|
||||
"notes_sha256": "e48399db78b18c6bd779412f7aeccf13fe78bf8c9bac22a4c418755acfab175c",
|
||||
"published_at": "2026-09-11T12:40:51Z"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
"""Independent frozen archive and packaged-server checks; never runs Cargo or GUI."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import tomllib
|
||||
import traceback
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
OUT = Path(__file__).resolve().parent
|
||||
ROOT = OUT.parents[1]
|
||||
PREP = ROOT / '.local/authoring-prerelease-20260911'
|
||||
NAME = 'ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm'
|
||||
ZIP = PREP / 'packages' / (NAME + '.zip')
|
||||
EXTRACT = OUT / 'extracted'
|
||||
PACKAGE = EXTRACT / NAME
|
||||
SOURCE = PREP / 'source'
|
||||
SOURCE_COMMIT = '234e01e27d6935d213097b345d91d369f808af82'
|
||||
BASE_COMMIT = '2ec72d4241fa8971fa6fe9f626937de9dfd7c7eb'
|
||||
EXPECTED_ZIP = '664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa'
|
||||
BINARIES = {
|
||||
'arcadia-studio.exe': 'd4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31',
|
||||
'arcadia-server.exe': 'efeeb89c0d2e0f93df4472cb3e9f48b2b722a37ae1043c90a5a56f2e845fe665',
|
||||
'libunwind.dll': 'f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4',
|
||||
}
|
||||
RESULT = {'checks': [], 'scope': sys.argv[1], 'started_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
|
||||
|
||||
|
||||
def check(condition, label):
|
||||
if not condition:
|
||||
raise AssertionError(label)
|
||||
RESULT['checks'].append(label)
|
||||
|
||||
|
||||
def sha(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open('x', encoding='utf-8', newline='\n') as f:
|
||||
json.dump(value, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n')
|
||||
|
||||
|
||||
def file_map(directory):
|
||||
result = {}
|
||||
for p in directory.rglob('*'):
|
||||
check(not p.is_symlink() and not (getattr(p.lstat(), 'st_file_attributes', 0) & 0x400), 'ordinary path: ' + p.relative_to(directory).as_posix())
|
||||
if p.is_file():
|
||||
result[p.relative_to(directory).as_posix()] = sha(p.read_bytes())
|
||||
return result
|
||||
|
||||
|
||||
def safe_name(name):
|
||||
p = PurePosixPath(name)
|
||||
return not p.is_absolute() and p.as_posix() == name and '\\' not in name and ':' not in name and all(c not in ('', '.', '..') and not c.endswith(('.', ' ')) and not re.match(r'(?i)^(con|nul|prn|aux|com[1-9]|lpt[1-9])(\.|$)', c) for c in p.parts)
|
||||
|
||||
|
||||
def git_blobs(ref, paths):
|
||||
raw = subprocess.run(['git', 'cat-file', '--batch'], input=''.join(ref + ':' + p + '\n' for p in paths).encode(), cwd=ROOT, capture_output=True, check=True).stdout
|
||||
result, cursor = {}, 0
|
||||
for p in paths:
|
||||
end = raw.index(b'\n', cursor)
|
||||
header = raw[cursor:end].split()
|
||||
check(len(header) == 3 and header[1] == b'blob', 'Git blob exists: ' + ref[:7] + ':' + p)
|
||||
size = int(header[2])
|
||||
result[p] = raw[end + 1:end + 1 + size]
|
||||
cursor = end + 2 + size
|
||||
check(cursor == len(raw), 'Git batch framing complete: ' + ref[:7])
|
||||
return result
|
||||
|
||||
|
||||
def artifact():
|
||||
check(not EXTRACT.exists(), 'clean extraction destination absent')
|
||||
check(ZIP.stat().st_size == 32618262 and sha(ZIP.read_bytes()) == EXPECTED_ZIP, 'frozen ZIP size and SHA256')
|
||||
with zipfile.ZipFile(ZIP) as archive:
|
||||
infos = archive.infolist()
|
||||
names = [i.filename for i in infos]
|
||||
check(len(infos) == 559, '559 ZIP members')
|
||||
check(len({n.casefold() for n in names}) == len(names), 'no duplicate or case-colliding ZIP names')
|
||||
check(all(safe_name(n) and n.startswith(NAME + '/') for n in names), 'one safe ZIP root without traversal or Windows special paths')
|
||||
check(all(not i.is_dir() and stat.S_IFMT(i.external_attr >> 16) in (0, stat.S_IFREG) for i in infos), 'ordinary file-only ZIP entries')
|
||||
check(all(i.date_time == (2000, 1, 1, 0, 0, 0) and i.flag_bits & 1 == 0 for i in infos), 'fixed timestamps and no encrypted entries')
|
||||
check(archive.testzip() is None, 'all ZIP CRCs')
|
||||
EXTRACT.mkdir()
|
||||
archive.extractall(EXTRACT)
|
||||
archive_hashes = {i.filename[len(NAME) + 1:]: sha(archive.read(i)) for i in infos}
|
||||
actual = file_map(PACKAGE)
|
||||
check(actual == archive_hashes, 'complete extracted file set and bytes equal ZIP')
|
||||
sums = {}
|
||||
for line in (PACKAGE / 'SHA256SUMS.txt').read_text().splitlines():
|
||||
match = re.fullmatch(r'([0-9a-f]{64}) (.+)', line)
|
||||
check(match is not None and safe_name(match[2]) and match[2] not in sums, 'well-formed unique checksum row')
|
||||
sums[match[2]] = match[1]
|
||||
check(sums == {p: h for p, h in actual.items() if p != 'SHA256SUMS.txt'}, 'checksum inventory covers every other file exactly')
|
||||
package = read_json(PACKAGE / 'package.json')
|
||||
inputs = package['input_sha256']
|
||||
generated = {'SHA256SUMS.txt', 'START-HERE.txt', 'THIRD-PARTY-NOTICES.txt', 'dependency-notices.json', 'documentation-check.json', 'package.json'}
|
||||
check(set(actual) - set(inputs) == generated and all(actual[p] == h.lower() for p, h in inputs.items()), '553 package input hashes plus exactly six generated files')
|
||||
check(package['version'] == '0.2.0-preview.authoring.20260911' and package['target'] == 'x86_64-pc-windows-gnullvm', 'version and target')
|
||||
check(all(actual[p] == h for p, h in BINARIES.items()), 'exact GUI, server and runtime hashes')
|
||||
for p in BINARIES:
|
||||
data = (PACKAGE / p).read_bytes()
|
||||
offset = struct.unpack_from('<I', data, 0x3c)[0]
|
||||
check(data[:2] == b'MZ' and data[offset:offset + 4] == b'PE\0\0' and struct.unpack_from('<H', data, offset + 4)[0] == 0x8664, 'ordinary PE x86_64: ' + p)
|
||||
check({p for p in actual if '/' not in p} == {'arcadia-server.exe', 'arcadia-studio.exe', 'libunwind.dll', 'LICENSE', 'README.md'} | generated, 'only declared portable top-level files')
|
||||
check({p.split('/')[0] for p in actual if '/' in p} == {'deployment', 'docs', 'examples', 'licenses'}, 'no harness, private profile, source checkout or credential-directory payload')
|
||||
for p in actual:
|
||||
if p.startswith(('docs/', 'examples/', 'deployment/')) or p == 'LICENSE':
|
||||
check((SOURCE / p).is_file() and sha((SOURCE / p).read_bytes()) == actual[p], 'public source document/example bytes: ' + p)
|
||||
RESULT['archive'] = {'path': str(ZIP), 'sha256': EXPECTED_ZIP, 'bytes': ZIP.stat().st_size, 'file_count': len(actual), 'files': actual}
|
||||
RESULT['package_claims'] = {k: package[k] for k in ('version', 'target', 'acceptance', 'build', 'build_evidence')}
|
||||
provenance()
|
||||
notices(actual)
|
||||
|
||||
|
||||
def provenance():
|
||||
gui_path = ROOT / '.local/behavior-creation-20260911/gui-verified/manifest.json'
|
||||
gui = read_json(gui_path)
|
||||
expected = {i['path']: i['sha256'].lower() for i in gui['inputs']}
|
||||
check(len(expected) == 253, 'original GUI manifest has 253 unique inputs')
|
||||
derived = git_blobs(SOURCE_COMMIT, list(expected))
|
||||
base = git_blobs(BASE_COMMIT, list(expected))
|
||||
differences = []
|
||||
for p, h in expected.items():
|
||||
check(sha(derived[p]) == h == sha((SOURCE / p).read_bytes()), 'original GUI input equals derived Git blob and checkout: ' + p)
|
||||
if sha(base[p]) != h:
|
||||
check(sha(base[p].replace(b'\r\n', b'\n').replace(b'\n', b'\r\n')) == h, 'exact LF-to-CRLF-only restoration: ' + p)
|
||||
differences.append(p)
|
||||
check(set(differences) == {'docs/openapi.json'} | {'crates/oa-desktop/src/semantic_review/' + p + '.rs' for p in ('artifacts_view', 'attachments_view', 'keyed_rows', 'keyed_tests', 'keyed', 'ordered')}, 'exact seven documented CRLF restorations')
|
||||
delta = subprocess.run(['git', 'diff', '--name-only', '--ignore-space-at-eol', BASE_COMMIT, SOURCE_COMMIT], cwd=ROOT, check=True, capture_output=True).stdout.decode().splitlines()
|
||||
check(delta == ['.gitattributes'], 'derived commit changes only preservation attributes beyond EOL bytes')
|
||||
origin = read_json(ROOT / 'harness/experiments/textual-authoring-20260911-local/provenance.json')
|
||||
tar_path = ROOT / '.local/linux-qa/runtime/source7-20260907T082753190288Z.tar.gz'
|
||||
check(sha(tar_path.read_bytes()) == 'cbb371a93a562e1e641d7629fe351e016d63e66fc9112499b3fed6d5cd171f78', 'original server source tar identity')
|
||||
server_expected = {i['path']: i['snapshot_sha256'].lower() for i in origin['source_comparisons']}
|
||||
check(len(server_expected) == 120, '120 independently recorded server source files')
|
||||
server_base = git_blobs(BASE_COMMIT, list(server_expected))
|
||||
tar_manifest = read_json(tar_path.with_suffix('').with_suffix('.tar.manifest.json'))
|
||||
with tarfile.open(tar_path) as tar:
|
||||
members = {m.name.removeprefix('./'): m for m in tar.getmembers() if m.isfile()}
|
||||
for p, h in server_expected.items():
|
||||
check(sha(tar.extractfile(members[p]).read()) == h == sha(server_base[p]) == sha((SOURCE / p).read_bytes()), 'server recorded tar equals base Git and staged input: ' + p)
|
||||
shared = [p for p in members if p in ('Cargo.toml', 'Cargo.lock', 'docs/openapi.json') or p.startswith('build/')]
|
||||
for p in shared:
|
||||
check(sha(tar.extractfile(members[p]).read()) == expected[p] == tar_manifest['files'][p].lower(), 'original server/GUI shared manifest or build input: ' + p)
|
||||
evidence = read_json(PACKAGE / 'package.json')['build_evidence']['evidence']
|
||||
check(evidence['binary_sha256'] == {k: v for k, v in BINARIES.items() if k.endswith('.exe')}, 'packaged build evidence binds both exact executables')
|
||||
check(SOURCE_COMMIT in evidence['source_identity'] and BASE_COMMIT in evidence['source_identity'], 'evidence identifies auxiliary and base sources distinctly')
|
||||
check('not claimed as the original build commit' in evidence['source_identity'] and 'Not a release-profile or reproducible-build claim' in evidence['profile'], 'packaged provenance retains explicit limitations')
|
||||
RESULT['provenance'] = {'gui_manifest_sha256': sha(gui_path.read_bytes()), 'gui_build_record': gui['build'], 'historical_gui_head': gui['head'], 'gui_inputs': 253, 'source_commit': SOURCE_COMMIT, 'base_commit': BASE_COMMIT, 'eol_restorations': differences, 'server_source_files': 120, 'shared_input_files': shared, 'tar_sha256': sha(tar_path.read_bytes())}
|
||||
|
||||
|
||||
def notices(actual):
|
||||
notice = read_json(PACKAGE / 'dependency-notices.json')
|
||||
check(notice['root_packages'] == ['oa-desktop', 'oa-server'] and len(notice['packages']) == 266, '266 notice packages and exact desktop/server roots')
|
||||
check(notice['cargo_lock_sha256'].lower() == sha((SOURCE / 'Cargo.lock').read_bytes()), 'notice lock equals exact historical source lock')
|
||||
for p, h in notice['workspace_build_inputs_sha256'].items():
|
||||
check(sha((SOURCE / p).read_bytes()) == h, 'notice common build input: ' + p)
|
||||
lock = tomllib.loads((SOURCE / 'Cargo.lock').read_text())
|
||||
locked = {(p['name'], p['version'], p.get('source')): p for p in lock['package']}
|
||||
referenced = set()
|
||||
def refs(items):
|
||||
for i in items:
|
||||
p = i['path']
|
||||
check(safe_name(p) and p.startswith('licenses/') and actual.get(p) == i['sha256'].lower(), 'bundled exact notice reference: ' + p)
|
||||
referenced.add(p)
|
||||
fonts = []
|
||||
for p in notice['packages']:
|
||||
key = (p['name'], p['version'], p['source'])
|
||||
check(key in locked and p['registry_checksum'] == locked[key].get('checksum'), 'notice identity/checksum occurs in historical lock: ' + p['name'] + '@' + p['version'])
|
||||
check(len(p['notices']) > 0, 'package has notice text: ' + p['name'])
|
||||
refs(p['notices'])
|
||||
for f in p['embedded_fonts']:
|
||||
check(len(f['notices']) > 0, 'embedded font notice present: ' + p['name'] + '/' + f['path'])
|
||||
refs(f['notices'])
|
||||
fonts.append({'package': p['name'], 'path': f['path'], 'sha256': f['sha256'], 'license': f['license']})
|
||||
if p['source'] is None:
|
||||
directory = SOURCE / 'crates' / p['name']
|
||||
files = {f.relative_to(directory).as_posix(): sha(f.read_bytes()) for f in directory.rglob('*') if f.is_file() and 'target' not in f.relative_to(directory).parts and f.name != '.cargo-ok'}
|
||||
check(files == p['local_source_sha256'], 'complete ordinary local-crate inventory: ' + p['name'])
|
||||
refs(notice['rust']['notices'])
|
||||
refs(notice['runtime']['notices'])
|
||||
check(notice['runtime']['sha256'] == BINARIES['libunwind.dll'], 'runtime notice is bound to shipped DLL')
|
||||
library_dir = ROOT / '.local/toolchain/rustup/toolchains/stable-x86_64-pc-windows-gnullvm/lib/rustlib/x86_64-pc-windows-gnullvm/lib'
|
||||
check(len(notice['rust']['target_libraries_sha256']) == 58, '58 compiler target libraries recorded')
|
||||
for name, h in notice['rust']['target_libraries_sha256'].items():
|
||||
check(sha((library_dir / name).read_bytes()) == h, 'retained compiler target-library bytes: ' + name)
|
||||
RESULT['notices'] = {'packages': len(notice['packages']), 'roots': notice['root_packages'], 'lock_sha256': notice['cargo_lock_sha256'], 'notice_reference_files': len(referenced), 'additional_license_files': sorted({p for p in actual if p.startswith('licenses/')} - referenced), 'embedded_fonts': fonts, 'rust_version': notice['rust']['version'], 'runtime_provenance': notice['runtime']['provenance'], 'scope': notice['scope']}
|
||||
|
||||
|
||||
def server():
|
||||
prior = read_json(OUT / 'artifact-result.json')
|
||||
check(prior['status'] == 'PASS' and file_map(PACKAGE) == prior['archive']['files'], 'previously verified extracted package is unchanged')
|
||||
runtime = OUT / 'server-run'
|
||||
runtime.mkdir()
|
||||
data_dir = runtime / 'data'
|
||||
temp = runtime / 'temp'
|
||||
temp.mkdir()
|
||||
windows = Path(os.environ['SystemRoot'])
|
||||
env = {'SystemRoot': str(windows), 'WINDIR': str(windows), 'PATH': str(windows / 'System32') + ';' + str(windows), 'TEMP': str(temp), 'TMP': str(temp)}
|
||||
exe = PACKAGE / 'arcadia-server.exe'
|
||||
RESULT['environment'] = env
|
||||
RESULT['processes'] = []
|
||||
for arg in ('--version', '--help'):
|
||||
output = subprocess.run([str(exe), arg], cwd=PACKAGE, env=env, capture_output=True, timeout=15, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
(runtime / (arg[2:] + '.stdout')).write_bytes(output.stdout)
|
||||
(runtime / (arg[2:] + '.stderr')).write_bytes(output.stderr)
|
||||
check(output.returncode == 0 and not output.stderr, arg + ' succeeds under clean runtime PATH')
|
||||
if arg == '--version':
|
||||
check(output.stdout.decode().strip() == 'arcadia-server 0.2.0', 'server reports underlying application version 0.2.0')
|
||||
check(not data_dir.exists(), 'help/version do not create explicit test data directory')
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
request_number = 0
|
||||
def request(port, method, path, payload=None):
|
||||
nonlocal request_number
|
||||
request_number += 1
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
|
||||
req = urllib.request.Request(f'http://127.0.0.1:{port}' + path, data=body, method=method, headers={} if body is None else {'Content-Type': 'application/json'})
|
||||
with opener.open(req, timeout=10) as response:
|
||||
raw = response.read()
|
||||
meta = {'method': method, 'path': path, 'status': response.status, 'headers': dict(response.headers), 'body_sha256': sha(raw)}
|
||||
prefix = runtime / f'http-{request_number:02d}'
|
||||
prefix.with_suffix('.body').write_bytes(raw)
|
||||
write_json(prefix.with_suffix('.json'), meta)
|
||||
return meta, raw
|
||||
def start(index):
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
port = s.getsockname()[1]
|
||||
out = (runtime / f'server-{index}.stdout').open('xb')
|
||||
err = (runtime / f'server-{index}.stderr').open('xb')
|
||||
args = [str(exe), '--bind', f'127.0.0.1:{port}', '--data-dir', str(data_dir)]
|
||||
process = subprocess.Popen(args, cwd=PACKAGE, env=env, stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
out.close()
|
||||
err.close()
|
||||
RESULT['processes'].append({'pid': process.pid, 'port': port, 'arguments': args})
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise AssertionError('packaged server exited before listening')
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.2):
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
raise AssertionError('packaged server did not listen within 15s')
|
||||
time.sleep(.05)
|
||||
return process, port
|
||||
def stop(process):
|
||||
process.terminate()
|
||||
code = process.wait(timeout=10)
|
||||
RESULT['processes'][-1]['exit_after_explicit_terminate'] = code
|
||||
process = None
|
||||
try:
|
||||
process, port = start(1)
|
||||
for path in ('/health', '/health/ready'):
|
||||
meta, raw = request(port, 'GET', path)
|
||||
check(meta['status'] == 200 and json.loads(raw) == {'status': 'ok'}, path + ' contract')
|
||||
meta, raw = request(port, 'GET', '/api/v1/openapi.json')
|
||||
check(meta['status'] == 200 and raw == (PACKAGE / 'docs/openapi.json').read_bytes(), 'served OpenAPI equals complete packaged source bytes')
|
||||
name = 'Independent package persistence — 救助 20260911'
|
||||
meta, raw = request(port, 'POST', '/api/v1/projects', {'name': name})
|
||||
created = json.loads(raw)
|
||||
check(meta['status'] == 201, 'one create returns 201')
|
||||
resource = str(uuid.UUID(created['resource_id']))
|
||||
model_id = str(uuid.UUID(created['model_id']))
|
||||
expected_summary = {'resource_id': resource, 'revision': 1, 'model_id': model_id, 'schema_version': 7, 'name': name, 'elements': 0, 'relations': 0, 'migration': None}
|
||||
check(created == expected_summary and resource != model_id, 'complete create summary with distinct fresh resource/model IDs')
|
||||
etag = f'"p-{resource}-r1"'
|
||||
headers = {k.lower(): v for k, v in meta['headers'].items()}
|
||||
path = '/api/v1/projects/' + resource
|
||||
check(headers.get('etag') == etag and headers.get('location') == path, 'create ETag and resource Location')
|
||||
meta, raw = request(port, 'GET', path)
|
||||
stored = json.loads(raw)
|
||||
model = {'schema_version': 7, 'id': model_id, 'alias': 'mission', 'name': name, 'description': '', 'elements': {}, 'relations': {}, 'diagrams': {}, 'artifact_links': {}, 'traces': {}}
|
||||
source = f'arcadia 7;\nproject mission {json.dumps(name, ensure_ascii=False)} id "{model_id}" {{\n description "";\n}}\n'
|
||||
expected = {'resource_id': resource, 'revision': 1, 'model': model, 'source_project': {'entry': 'project.arcadia', 'files': {'project.arcadia': source}}}
|
||||
check(meta['status'] == 200 and stored == expected, 'GET equals independently predicted complete Project and SourceBundle')
|
||||
check({k.lower(): v for k, v in meta['headers'].items()}.get('etag') == etag, 'GET ETag equals created revision')
|
||||
disk = data_dir / (resource + '.json')
|
||||
disk_bytes = disk.read_bytes()
|
||||
check(json.loads(disk_bytes) == expected, 'durable disk envelope equals complete expected GET')
|
||||
check({p.name for p in data_dir.iterdir()} == {'.arcadia.lock', disk.name}, 'private store contains only one project and lock')
|
||||
stop(process)
|
||||
process = None
|
||||
process, port = start(2)
|
||||
meta, raw = request(port, 'GET', '/health/ready')
|
||||
check(meta['status'] == 200 and json.loads(raw) == {'status': 'ok'}, 'restarted packaged server ready')
|
||||
meta, raw = request(port, 'GET', path)
|
||||
check(meta['status'] == 200 and json.loads(raw) == expected, 'restart retains exact complete Project and SourceBundle')
|
||||
check({k.lower(): v for k, v in meta['headers'].items()}.get('etag') == etag and disk.read_bytes() == disk_bytes, 'restart retains revision, ETag and every durable envelope byte')
|
||||
RESULT['persistence'] = {'resource_id': resource, 'model_id': model_id, 'revision': 1, 'etag': etag, 'disk_sha256': sha(disk_bytes), 'expected': expected, 'stop_kind': 'explicit process termination after acknowledged durable create; not a graceful-drain claim'}
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
stop(process)
|
||||
check(file_map(PACKAGE) == prior['archive']['files'], 'server execution leaves complete extracted package unchanged')
|
||||
check(sha(ZIP.read_bytes()) == EXPECTED_ZIP, 'frozen ZIP unchanged after independent runtime checks')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
result_path = OUT / (sys.argv[1] + '-result.json')
|
||||
if result_path.exists():
|
||||
raise SystemExit('Refusing to overwrite prior evidence: ' + str(result_path))
|
||||
try:
|
||||
{'artifact': artifact, 'server': server}[sys.argv[1]]()
|
||||
RESULT['status'] = 'PASS'
|
||||
except Exception as error:
|
||||
RESULT['status'] = 'FAIL'
|
||||
RESULT['error'] = repr(error)
|
||||
RESULT['traceback'] = traceback.format_exc()
|
||||
RESULT['finished_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
RESULT['script_sha256'] = sha(Path(__file__).read_bytes())
|
||||
write_json(result_path, RESULT)
|
||||
print(json.dumps({k: RESULT[k] for k in ('scope', 'status', 'started_utc', 'finished_utc')}, indent=2))
|
||||
print('checks:', len(RESULT['checks']))
|
||||
if RESULT['status'] == 'FAIL':
|
||||
print(RESULT['traceback'])
|
||||
sys.exit(1)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm.zip",
|
||||
"bytes": 32618262,
|
||||
"sha256": "664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa",
|
||||
"url": "http://217.177.34.108:3000/attachments/48a283b7-5391-4677-9427-02a772e5e7e4",
|
||||
"readback": "PASS"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm.zip.sha256",
|
||||
"bytes": 143,
|
||||
"sha256": "ea553a85f35d2d48f229dd4cd00ac0c3d4a6e9dafa58273004b2c49d1279f856",
|
||||
"url": "http://217.177.34.108:3000/attachments/08164a39-9292-4b2d-9856-c6ec41d9b8e2",
|
||||
"readback": "PASS"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": 3,
|
||||
"name": "VALIDATION.md",
|
||||
"bytes": 29308,
|
||||
"sha256": "c2a70be735c780d3e468d5f12643fb00be75673434211366739926536bbbe9b9",
|
||||
"url": "http://217.177.34.108:3000/attachments/9ff53361-eedf-46a0-aa4e-34f11bb0f302",
|
||||
"readback": "PASS"
|
||||
}
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Canvas union a57c334 source-review archive
|
||||
|
||||
This immutable source-review snapshot supports combined source commit `a57c334b302eb13352b4bb65df1c75d94d002348`, selectively integrating 42 source files in the isolated canvas worktree. Baseline: `32334c66317692befb6517a917dc7ae90641c11a` with production `865483b4c66361a01fe0d9e731859220bafb4136`. Peer: `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`.
|
||||
|
||||
The seven payloads preserve six private source/review reports plus the frozen build-input manifest. `manifest.json` maps every original main-checkout private path to its archive path, byte size, original SHA256 and copied SHA256. Original report text and private references remain unchanged; use that mapping to resolve archived references without assuming private files exist on another host. `readback.json` independently rereads originals and copies and records the second comparison.
|
||||
|
||||
Root reported verify-head PASS at 2026-09-11 19:44:32 UTC: all 529 raw input files, 9,712,849 bytes, matched a57c334 Git bytes. The archived `inputs.json` SHA256 is `47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3`. Its original capture at 19:42:03 UTC describes an uncommitted candidate; that historical wording is preserved. The later root equality result establishes the commit mapping. This archiver verified the manifest payload and its 529-entry/byte totals; it did not independently rerun the source-to-Git equality check.
|
||||
|
||||
Review order and scope:
|
||||
|
||||
- `next-integration-865483b-review.md`: historical preflight, exact peer/source boundaries and risks. Later work supersedes its pending integration status.
|
||||
- `canvas-prerequisite-union-report.md`: implemented App/chooser/form/session/workflow union, preserving child-work and corrected source cards.
|
||||
- `canvas-pan-independent-review.md`: independent pre-implementation transform and clipping obligations; not a final-candidate verdict.
|
||||
- `canvas-renderer-union-report.md`: B4/pan implementation and preservation checks; its pending-review wording predates the final reviews.
|
||||
- `canvas-root-prerequisite-final-review.md`: no actionable finding in the reviewed App/root prerequisite union; exact source hashes.
|
||||
- `canvas-renderer-final-review.md`: no material finding in three frozen renderer files, preserving the distinction between arithmetic/Program tests and native pixel evidence.
|
||||
|
||||
Peer a31's 729 tests and strict Clippy were already closed; they validate a31, not this union. Root separately reports newer allocation-planner 29 tests passed, with its UI still unaccepted; that separate contribution is not accepted by this archive. Other historical preflight readiness statements remain scoped to their observation time.
|
||||
|
||||
This is source evidence only. At handoff, focused Windows compilation was running and no combined test result existed. Root will archive closed gates and add the source-version record separately. No build, test, compiler, GUI, WSL, Git/index or Gitea operation was performed to create this archive. Raw-input ZIPs, temporary source reconstructions, live logs, binaries, package payloads and unrelated private work are excluded.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Independent camera/B4 union obligations
|
||||
|
||||
Codex-ytr83r /root/windows_release_gap_review, 2026-09-11. Read-only review of frozen root32334c6 and fetched peer a31b40d; no review or edits of the worker's changing union files. No compiler, tests, GUI, WSL/Linux, Git mutation or product/library research. Actual union-diff review remains separate after worker freeze.
|
||||
|
||||
## Primary evidence
|
||||
|
||||
- Root32334c6 `crates/oa-desktop/src/behavior.rs`: `box_text_clip`, `Scene::draw`, `Scene::hit`. Header drawing clips the padded card in frame coordinates and reapplies only scale to its child frame. Root `behavior/scenario_lifecycle/render.rs`: `birth_label_clip`, `Rendering::draw_markers` does the same for Create text; marker identity/lifetime geometry and selection priority are already present.
|
||||
- Peer a31b40d `behavior.rs`: parent frame translates `camera_pan` before scale; cursor inversion is `(position_in(bounds)-camera_pan)/scale`. Peer `behavior/canvas_workspace.rs::change` rejects nonfinite pan deltas/results and invalid zoom inputs; `canvas_workspace/viewport.rs` owns gesture cancellation/focus and renderer layering. Camera is presentation state, with no model commands in this shared module.
|
||||
- Actual locked dependencies are registry `iced_graphics0.14.0`, `iced_wgpu0.14.0`, `iced_tiny_skia0.14.0`, `iced_widget0.14.2`, `iced_core0.14.0`, in main `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. They are not a located Git fork checkout.
|
||||
- `iced_graphics/src/geometry/frame.rs::with_clip` calls draft then paste. WGPU `src/geometry.rs:116` creates an identity transform; `draft:412` calls that constructor; `paste:416` appends child primitives without another transform or parent-clip intersection. `fill_text:306` records transformed text position/font size and the child's own clip bounds. Tiny-skia draft/paste follows the same identity/noninheritance rule.
|
||||
- `iced_widget/src/canvas.rs:300` adds the widget bounds-origin translation outside `Program::draw`. Thus program text/clip coordinates must remain canvas-local. `iced_core/src/rectangle.rs::intersection` computes float max/min then positive dimensions; these operations are not a finite-input validator.
|
||||
|
||||
## Five bounded test obligations
|
||||
|
||||
1. **Same coordinate map everywhere:** world point maps to `camera_pan + scale * point`. Parent and clipped child frames must translate pan, then scale, exactly once. Never add widget bounds origin, subtract the clip origin, or scale pan. For header rect(100,200,204,70), scale2, pan(-200,-400), viewport(300,100), expected padded clip is(20,16,280,84), and title anchor is(24,24). Scale-only clips incorrectly disappear; scale-before-pan text lands at(-176,-376). Equivalent Create capsule(100,200,140,28) gives clip(16,8,248,40) and text anchor(16,12). Keep original text and padding semantics.
|
||||
2. **Clipping is an intersection, not a relocation:** cover positive/negative pan, scales0.5/1/2 plus existing minimum scale0.05, partial visibility on every viewport edge, fully offscreen None, and nonzero widget origin. Use actual canvas viewport size, not world scene/content size or child clip size. Preserve the outer viewport/Scrollable renderer layer for ancestor visibility; do not turn a local clip into absolute screen coordinates. A negative/large finite pan may legitimately hide the entire scene; do not introduce a new camera clamp to make tests pass.
|
||||
3. **Hit inversion and existing priorities:** route a Create capsule center and neutral header through actual Program events with nonzero bounds origin, pan and nonunit scale. Selecting Create must still return its occurrence, neutral header its lifeline. Retain existing minimum-zoom regression that marker halos do not steal adjacent marker/header hits. Screen-space hit halos remain constant through world thresholds divided by scale. Include a pointer that is within the world shape but outside the visible widget; it must not activate it. Preserve ordered raw event positions and stale-gesture cancellation when camera/bounds/context changes; do not restore the old double Scrollable-offset adjustment.
|
||||
4. **Finite-domain robustness:** test NaN/infinite pan components, invalid scale, invalid viewport dimensions, and arithmetic overflow before rectangle intersection. With NaN clip x, f32 max/min can yield a plausible full-width viewport clip instead of None, so intersection alone is insufficient. Keep finite rectangle origins/sizes/transformed endpoints and strictly positive padded dimensions as helper preconditions. Peer camera event guards already prevent ordinary nonfinite event state, so this is a robustness obligation, not a demonstrated native defect. Do not broaden this into a new global camera architecture.
|
||||
5. **Presentation-only invariance and evidence limits:** camera pan/zoom/drawing must preserve complete Project, SourceBundle, IDs, lifetime intervals, activation rectangles, message endpoints and semantic history. Compare source/model before/after actual camera messages in the existing harness where available. Mathematical clip-helper tests alone cannot prove that draw() reapplies child translation/scale; inspect both call sites against Iced behavior, and reserve actual pixel containment for root's later native gate. No new rendering framework is needed for this union.
|
||||
|
||||
No material finding against an actual frozen union is claimed here. These are concrete integration falsifiers and review obligations. Root owns combined source, validation and native/release acceptance; the earlier native header fix and peer729-test evidence retain their original scope.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Canvas prerequisite/App union
|
||||
|
||||
Codex-ytr83r /root/wsl_startup_review, 2026-09-11. Implemented only in `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/canvas-union-ytr83r-20260911`, branch `codex/canvas-union-ytr83r-20260911`, root-provided baseline `32334c6`. Root owns the combined integration, review, formatting and all execution gates.
|
||||
|
||||
## Result and provenance
|
||||
|
||||
22 files changed by this worker, 844026 bytes at handoff. The 21 prerequisite files below exactly match published peer `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`, verified by original Git-blob bytes versus written SHA256. Existing files were checked unchanged against baseline before writing. Unchanged files within the leased trees were not rewritten.
|
||||
|
||||
App was merged by applying only `b2c3e54001fc19a05423bcd5808ceea78d13ff59 → a31b40d0...` App hunks to our source; it was not replaced by the peer App. The only baseline App difference from b2c was the responsibility-preserving child-work function. After integration, App differs from the peer by the same 52 added/12 removed lines; its entire `add_element` function matches baseline exactly (9,563 characters after LF normalization, SHA256 `8918DF74451FCCB586D4582F138D89B039674A58D6ADE02BA04DF6CF69D0A4C2`).
|
||||
|
||||
The prerequisite provenance is the reviewed published chain:
|
||||
|
||||
- `7f081ad4572a7f45d736377c9ebc4a039032c58a`: production reference chooser and scenario form focus/reveal.
|
||||
- `8895dd2e0f87c0f16085bf4b478b8c0fbed38685`: current parent-form focus and stale/mount guards.
|
||||
- `80f25882143d977ca4984691740c3a7ffd45aedd`: replacement-session reset and visible-scroll correction.
|
||||
- `3c9e6eeb64ce3efc03b29890b82199a42c21f335`: boxed semantic workflow events and constructor adaptations.
|
||||
- Final `a31b40d0...` supplies the completed camera App wiring, helper visibility and cleanup. No unrelated ancestor baseline was imported.
|
||||
|
||||
Root explicitly extended this worker's scope to `workflow.rs` and `app/review_navigation_tests.rs` for the boxed Event interface. Those changes are included. No other additional path was edited.
|
||||
|
||||
## Preserved behavior and interfaces
|
||||
|
||||
`behavior/creation.rs`, `behavior/source_draft.rs` and `tests.rs` remain exact baseline files, confirmed by scoped Git diff exit 0. Thus the staged-owner caption, minimum source-card layout and its tests, and all three child_work regression tests are retained. Peer regressions in the first two files were deliberately excluded. The root child-work function retains explicit allocation/role relation ID transfer, unambiguous later-child performer inheritance, and execute_batch candidate admission.
|
||||
|
||||
App now combines reference overlay, canvas focus root and scenario focus root; reference-message admission/reconciliation; camera lifetime/boundary guards and stamped toolbar dispatch; parent FormShown focus; camera-aware scenario handling; the final obsolete-action cleanup; and boxed semantic Event dereference. The prerequisite helpers supply current draft/session/generation reference identity, chooser adapters and actual mounted tests. Workflow uses `Semantic(Box<semantic_review::Event>)` and the `Event::semantic` constructor.
|
||||
|
||||
Root/other owners must provide the matching a31 interfaces in their leased files: `behavior::canvas_workspace::{Intent, Scope::input_owner, keyboard_enabled, focus_root, focus_task}`; topology/scenario camera events and camera boundary/zoom methods; scenario `FormShown`, `focus_stamp`, `take_form_focus`; and the behavior module declarations for form_session/canvas_workspace/runtime lifecycle. The root-owned process_canvas change must accompany this App. No missing additional prerequisite interface was identified within this worker's completed scope.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
Scoped `git diff --check` passed after all writes. Every copied prerequisite matched its pinned original SHA256, the protected files were unchanged, and the whole child-work function matched baseline. The remaining `Event::Semantic(...)` in semantic_review/batch.rs is a pattern-match arm; construction uses the boxed helper. This is source/readback verification only. No compiler, tests, Clippy, GUI, native, WSL, Git index/commit, main-checkout source or Gitea mutation was performed.
|
||||
|
||||
The peer's prior 729/Clippy result remains evidence for a31 only, not this B4/header/child-work union. Root must review and run the combined gates, including mounted chooser/parent-focus/camera checks, existing contextual/child-work regressions and pan-aware lifecycle/header clipping and hit tests. Navigation and private allocation drag are outside this change.
|
||||
|
||||
## Exact handoff inventory
|
||||
|
||||
Paths below are relative to `crates/oa-desktop/src/`. SHA256 values identify this pre-format handoff; later root edits or formatting require new fingerprints.
|
||||
|
||||
| Path | Bytes | SHA256 |
|
||||
| --- | ---: | --- |
|
||||
| `app.rs` | 220191 | `6EB8B4BCD8F73D4BFDAACF30985D8292547440D65EAF49F6BB82AC158FBC46D3` |
|
||||
| `app/reference_authoring.rs` | 11250 | `38E033E23E8135032F70117A376A5FE7BC68127C466559A35D7EC200932E3F77` |
|
||||
| `app/reference_authoring/tests.rs` | 20067 | `B96DDC6BFF7F8FDE97720E18A92070F891C1340348D2E19C7C9C5A7BDA650CE8` |
|
||||
| `app/reference_authoring/tests/mounted.rs` | 32805 | `14A4914A1C86E50C395672DAB8FE80D7AF679A79C9082890B509FAD7B7C543D9` |
|
||||
| `app/review_navigation_tests.rs` | 7049 | `872FA91479DAD5436E139FFCAECE270323E9F5DAEEB7A4D80C2C2534F48BC21B` |
|
||||
| `behavior/form_session.rs` | 3941 | `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61` |
|
||||
| `behavior/scenario/focus.rs` | 9728 | `73A9EA10AD7312086081B8688396A21AB4251C9FAECC2DF6D984B702F3963533` |
|
||||
| `forms.rs` | 105544 | `EA124F479FCD2D3978AC3CB8EB81FEC4A7AAE5E4F4414C4BDD54849E954DEDF9` |
|
||||
| `forms/data_fields.rs` | 49285 | `A540A0D451BFF56A96B1D28643B2E8C0649F3846350C25C5B154729C0538E77C` |
|
||||
| `forms/reference_chooser.rs` | 12130 | `B60AF363BE333AF048859E51C28E8C989CB5C95D696CAD16C8EEC15F78D57A12` |
|
||||
| `forms/reference_chooser/adapter.rs` | 10447 | `87AB4BC3BF27FD4B5A458791CF62C7BA8E44E9EBF846D8B851B8B6FFB454CD64` |
|
||||
| `forms/reference_chooser/tests.rs` | 27835 | `8E98F8A3FCB814D4A201CC85B3DB87A689FD3D2CE487F8A2FE312FC296F93377` |
|
||||
| `forms/reference_chooser/trigger.rs` | 13336 | `34D0ACECFE6BED193970AA473CC3313984971264B86D7B362A6B84C23D013C0A` |
|
||||
| `forms/reference_chooser/trigger/focus.rs` | 9923 | `F1E79F4B03B2F9FD1CDC335054D4AE177DC9E759B6EA3D334B2BCCA39B57191B` |
|
||||
| `forms/reference_chooser/widget.rs` | 33804 | `D66CC17AF7A48BB8598BE683F084BB08FA7AF7304F9636F0A1BEA94C5CF6C727` |
|
||||
| `semantic_review.rs` | 3432 | `8DD1E93783D63A663C7E56C28775A5C389CFD7A6B562013D6A95C891AFA21ED8` |
|
||||
| `semantic_review/batch.rs` | 34485 | `A462EB627AE19348072775CF8E60B17D62F6B682B74CC413906D493FBA1F0B3C` |
|
||||
| `semantic_review/batch_tests.rs` | 27546 | `B67C4D7DCABAC710CF3D30730A8DB323180A558A415E2D117C868CDFA4804619` |
|
||||
| `semantic_review/row.rs` | 38159 | `B08FDC503C6C4D2FF99ADAC7E18C9BAA861BF86155E00B55D1E492430980D0F2` |
|
||||
| `semantic_review/tests.rs` | 32480 | `077EC6501DFAAB0B05EA3987943B204F08352952296D313555051D998CCD232E` |
|
||||
| `semantic_review/view.rs` | 57626 | `7CC7DD213AB01DADAD5DEBAEE868C30A1099466602869A799EBDF9B680E8EAF0` |
|
||||
| `workflow.rs` | 82963 | `D9E7B11A86FD3EC79D91F0C9BC9CEBD5A2043CDAA7261622454FB7730AD8E060` |
|
||||
|
||||
Primary source: [a31b40d0 publication](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/a31b40d0f8551ed87c8cd66250b9afb4809b3a79). Root's authorized integration scope is [issue 2 comment 490](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-490). The earlier private preflight remains `.local/next-integration-865483b-review.md`; this note records actual implementation, not inherited acceptance.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Frozen canvas renderer union — independent source review
|
||||
|
||||
Reviewer: Codex-ytr83r /root/windows_release_gap_review, 2026-09-11. Verdict: **no material integration finding in the three reviewed frozen files**. This is a source-review verdict, not compiler, test or native acceptance. Root owns combined integration and validation.
|
||||
|
||||
Reviewed worktree: `.local/canvas-union-ytr83r-20260911`, root baseline `32334c66317692befb6517a917dc7ae90641c11a`, peer renderer/camera source `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`.
|
||||
|
||||
| Reviewed file | SHA256 |
|
||||
| --- | --- |
|
||||
| crates/oa-desktop/src/behavior.rs | 82DCAAED9EB17F15329F80BCC7442FFF94C17D426A50DEA68455339F60FFA246 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs | BF1C05EA1E709B3503D8BCF92F091446FD7A9B80B1A29C5A55919DF58F3B0271 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs | A95201F7D0DCC88C9C54863F0E95E09C1DB204668743ABBD32C495B577EA53EF |
|
||||
|
||||
## Findings and preservation
|
||||
|
||||
All three hashes matched the worker's freeze. The parent frame, header text child frame and Create-label child frame each apply screen-space translation before uniform scale. Both clip helpers compute `pan + scale * world` once and intersect the actual parent frame size. Neither adds the widget origin or subtracts the clip origin. The reviewed draw call sites therefore agree with the underlying Iced transform/clip behavior.
|
||||
|
||||
The numerical regression correctly distinguishes missing or misordered pan. For header rectangle(100,200,204,70), scale2, pan(-200,-400), viewport(300,100), the padded clip is(20,16,280,84). For Create rectangle(100,200,140,28), the corresponding clip is(16,8,248,40). Other asserted positive/negative pan, partial-edge, fully offscreen and nonfinite-pan cases were checked against their arithmetic.
|
||||
|
||||
Independent readback from the frozen Git blobs and candidate files, explicitly decoded as UTF-8 and normalizing line endings, confirmed:
|
||||
|
||||
- The entire `impl Scene` geometry/hit section matches root32334c6 except initialization of `scroll_offset` becoming `camera_pan`.
|
||||
- The lifecycle rendering prefix before `draw_markers` and suffix from `marker_distance` remain unchanged.
|
||||
- All 15 existing lifecycle render test names remain, including minimum-scale marker/header and ordinary-message priority regressions. Two tests were added: `panned_header_and_create_clips_use_screen_translation_and_actual_viewport` and `real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity`.
|
||||
|
||||
The new Program test exercises OA/SA header, Create and Delete selection through mouse events at scales0.5/1/2, positive/negative pan and nonzero widget origin. Its `canvas::Program::update`, `Action::into_inner`, message matching and fixture API usage showed no source-level mismatch. Source inspection also confirmed the intended single Select publication for the tested press/release paths. No test execution by this reviewer is claimed.
|
||||
|
||||
An initial comparison using Python's platform-default text decoding produced a comparison failure around Unicode punctuation. Repeating the readback with explicit UTF-8 for both sides resolved that reviewer-tool issue and established the preservation results above; it did not require or identify a production change.
|
||||
|
||||
## Direct Iced provenance
|
||||
|
||||
The actual locked dependencies are registry packages, not a located Git fork: `iced_graphics0.14.0`, `iced_wgpu0.14.0`, `iced_tiny_skia0.14.0`, `iced_widget0.14.2` and `iced_core0.14.0`. The inspected copies are under main `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f`, matching Cargo.lock.
|
||||
|
||||
- `iced_graphics/src/geometry/frame.rs::with_clip` drafts a child frame and then pastes it.
|
||||
- `iced_wgpu/src/geometry.rs:116` initializes the frame transform to identity. `draft:412` constructs a fresh frame with the supplied clip; `paste:416` appends its primitives without reapplying the parent transform or intersecting the parent's clip. `fill_text:306` records transformed text position/font size and the child's clip bounds.
|
||||
- Tiny-skia's `geometry.rs` uses the same fresh-identity draft and direct-paste behavior.
|
||||
- `iced_widget/src/canvas.rs:300` applies widget-origin translation outside `Program::draw`, establishing canvas-local coordinates for these clip and text operations.
|
||||
- `iced_core/src/rectangle.rs::intersection` uses floating-point min/max followed by positive-dimension checks; it is not itself a general finite-input validator.
|
||||
|
||||
## Limits and remaining root validation
|
||||
|
||||
The new Program test does not dispatch App camera messages: its separately held Document remains untouched. It supports Scene hit identity and lifecycle equality, not complete App camera/history integration. Existing App/workspace camera, source and Undo/Redo gates must remain in combined validation.
|
||||
|
||||
The clip helpers reject nonfinite pan and invalid scale. General invalid rectangle/viewport coordinates and transformed arithmetic overflow are not fully covered or newly guarded. Production Scene geometry and peer camera event validation supply the ordinary finite-domain assumptions; this review found no demonstrated reachable regression requiring a broader camera rewrite. The original obligations note documents additional robustness falsifiers and remains unchanged.
|
||||
|
||||
Arithmetic clip tests do not establish native glyph containment. The reviewed child-frame call sites match the inspected Iced implementation, but actual rendered pixels, ancestor clipping, combined compiler/tests/Clippy and runtime acceptance remain root-owned. Earlier root707 and peer729 results retain their original source/runtime scope and do not validate this union.
|
||||
|
||||
No candidate source edits, compiler/test/native execution, Git mutations or WSL/Linux actions were performed by this reviewer. This file persists the already completed review; no additional investigation was performed to write it.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Isolated canvas renderer union
|
||||
|
||||
Owner: Codex-ytr83r /root/linux_build_plan. Worktree `.local/canvas-union-ytr83r-20260911`; baseline32334c66317692befb6517a917dc7ae90641c11a, preserving production865483b renderer. Peer pinned a31b40d0f8551ed87c8cd66250b9afb4809b3a79; known merge base c6d296a7e22c8f126834a1e217b88551634dd5cf. Only the three owned worktree files below were edited; main source, Git/index, compiler, native UI and WSL were untouched.
|
||||
|
||||
## Implemented
|
||||
|
||||
- behavior.rs: imported peer's scenario_scroll observer adaptation (process argument, Page-only details scrolling) and canvas_workspace/form_session module declarations, retaining unconditional scenario_lifecycle. Merged peer camera_pan input/draw mapping, Lifeline palette admission, ordinary row drag/release selection and cross-operand hint. Retained root B4 scene geometry, lifetimes, markers, clipping, marker priority and model/source construction. The existing pointer-order regression now covers the replacement camera-pan backend.
|
||||
- Header text and Create label clipping use the same local screen transform: pan + scale × world. Both fresh clipped child frames explicitly translate(pan) THEN scale(scale), just like the parent. Clip rectangles intersect actual parent frame size; widget absolute origin is not added. Nonfinite pan is rejected by clip helpers.
|
||||
- scenario_lifecycle/render.rs changes are limited to the pan argument for draw_markers, child transform, and birth_label_clip transform/guard. No marker/lifetime/hit semantics changed.
|
||||
- All15 existing lifecycle render tests remain, including .05/.1 minimum-zoom marker priority. Added2 regressions: independent absolute clip expectations for positive/negative pan at .5/1/2, partial viewport edges/offscreen/nonfinite cases; actual canvas::Program mouse-event selection for OA/SA header, Create and Delete identities with nonzero pan, nonunit zoom and nonzero widget origin. Unicode/source/lifecycle values remain unchanged.
|
||||
|
||||
Independent reviewer counterexample is included: rect100,200,204,70; pan(-200,-400),scale2,viewport300×100 produces header clip20,16,280,84; Create140×28 clip16,8,248,40. A scale-before-pan ordering would fail these expectations. The tests do not claim pixel validation or invoke a physical renderer; direct source inspection verifies both child-frame call sites.
|
||||
|
||||
## Validation and handoff
|
||||
|
||||
Scoped rustfmt2024 with skip_children=true formatted only these3 files; subsequent --check exit0. Correct-worktree git diff --check exit0. No compiler/test/native execution. Read-only source comparison against32334c6 proved the entire impl Scene geometry/hit section equal apart from initialization field scroll_offset→camera_pan; the B4 render/lifetime/hit prefix before draw_markers is byte-equal after line-ending normalization. Existing lifecycle test names were all retained (15→17).
|
||||
|
||||
Root owns canvas_workspace/**, scenario.rs/workspace/**, topology/** and guide integration; prerequisite worker owns App/forms. Renderer interface is Scene.camera_pan (screen units), scenario_scroll::observe(context,area,process,restore,content), and Rendering::draw_markers(frame,selected,scale,pan). Root was notified about remaining scene.scroll_offset expressions in its workspace/tests.rs:1193/1198 at the time of inspection; those belong to root's interface adaptation.
|
||||
|
||||
windows_release_gap_review supplied exact Iced draft/paste/transform obligations and has been asked to inspect these final candidate hashes. At this report write, candidate critique is pending. Root must compile and run combined candidate tests/Clippy and actual pan/zoom/viewport native checks before acceptance. Peer729 and prior root707 results do not validate this union.
|
||||
|
||||
## Frozen worker hashes
|
||||
|
||||
| Worktree file | SHA256 |
|
||||
| --- | --- |
|
||||
| crates/oa-desktop/src/behavior.rs | 82DCAAED9EB17F15329F80BCC7442FFF94C17D426A50DEA68455339F60FFA246 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs | BF1C05EA1E709B3503D8BCF92F091446FD7A9B80B1A29C5A55919DF58F3B0271 |
|
||||
| crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs | A95201F7D0DCC88C9C54863F0E95E09C1DB204668743ABBD32C495B577EA53EF |
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
# Final root/prerequisite integration source review
|
||||
|
||||
Codex-ytr83r /root/wsl_startup_review, 2026-09-11. Read-only review of the isolated `.local/canvas-union-ytr83r-20260911` worktree against baseline `32334c6` and frozen peer `a31b40d0f8551ed87c8cd66250b9afb4809b3a79`.
|
||||
|
||||
**No actionable integration finding in the reviewed scope.** This does not establish compilation or native acceptance. Root owns formatting and combined gates; the separate pan/B4 renderer implementation is not re-certified by this App/root-prerequisite review.
|
||||
|
||||
## Source comparison
|
||||
|
||||
The 17 root-owned canvas paths match a31 except the intentional guide union. App's SHA256 remains `6EB8B4BCD8F73D4BFDAACF30985D8292547440D65EAF49F6BB82AC158FBC46D3`, the exact prerequisite handoff reviewed earlier. Its sole difference from a31 is the preserved child-work correction (52 additions, 12 removals). The earlier full-function equality against baseline therefore still applies: `add_element` retains responsibility relation IDs, leaf-to-child transfer, unambiguous later-child inheritance and execute_batch candidate validation.
|
||||
|
||||
The guide correctly retains B4 lifecycle/uncertainty text and its search terms. The empty-path wording now directs Work (W) and a canvas click; the permanent Fragment palette instructions agree with the new controls. Existing advanced trace guidance is retained. No competing guide behavior was overwritten.
|
||||
|
||||
Scoped baseline comparisons confirm `behavior/creation.rs`, `behavior/source_draft.rs` and `tests.rs` unchanged. The staged-owner caption, minimum-card layout/tests and three child_work regressions survive. The prerequisite source, its boxed semantic workflow adaptations and published provenance are recorded in `.local/canvas-prerequisite-union-report.md`; this review did not rewrite them.
|
||||
|
||||
## Former First event and mounted successors
|
||||
|
||||
No stale `CanvasEvent::First`, `Shelf::Tools` or `ScrollArea::Canvas` use remains in the inspected App/process/scenario/topology paths. The final event vocabulary, App dispatch and workspace controls agree.
|
||||
|
||||
The replacement is actual user input through the permanent palette, not a renamed synthetic First test. At `behavior/scenario/workspace/tests.rs:352`, `production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas` mounts the App at 1100×700, visits both OA and SA empty scenarios, clicks the actual L control then the canvas, and requires the correct owner's Lifeline draft. It opens the staged source child, preserves viewport bounds, uses Escape, and checks retained invocation/raw name plus unchanged snapshot/source/Undo/Redo. App's `BeginOccurrence` Lifeline branch calls `State::update(Event::Start)`; other occurrence tools retain slot/lifeline admission through `begin_occurrence`. Dirty/pending admission is checked before either branch. The palette keeps lifeline, message styles, action, observation, reuse and Fragment reachable.
|
||||
|
||||
The same-size owner/document successor is `scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport` at line 1781. It reuses the same Mounted instance across OpenUse from owner A to B, requires the presentation viewport to be absent before settling, then observes the actual viewport again at unchanged dimensions. It subsequently replaces App/document while keeping the same semantic owner UUID, proves the document identity changed, again requires remeasurement, and validates Fit and unchanged source/history. This exercises the workspace sensor key `(context.document, owner)`, rather than assuming a resize notification will occur.
|
||||
|
||||
The mounted Open/Back successor at line 1828 also retains the caller's camera pan/zoom, selected occurrence/tool/panel and actual Details offset across definition navigation and preselection changes. It checks the observer consumed the one-shot restoration; camera pan is not a synthetic scrollable offset. The five parent-focus regressions remain present, including saved/chained Tasks and changed owner/document/mount/modal conditions.
|
||||
|
||||
## Interface and behavioral seams inspected
|
||||
|
||||
App's camera dispatch and root focus/chooser wrappers correspond to the pinned scenario/topology/canvas helpers. Scenario supplies current `focus_stamp`, `take_form_focus`, `FormShown`, `camera_boundary` and `camera_zoom`; process dispatch supplies the equivalent current-context/lifetime and pending/draft/source guards. Module declarations for canvas_workspace, form_session and scenario focus are present in the combined source. No additional missing prerequisite interface was identified.
|
||||
|
||||
Owner/document/revision guards remain on passive viewport/Details events; strict current context and clean-edit guards remain on authoring. The separate camera epoch permits its own ordered batch but invalidates stale presentation work on external boundary changes. Existing process/contextual identity and qualified selection behavior are retained from the frozen peer. This was a bounded integration review, not a new audit of every inherited peer algorithm.
|
||||
|
||||
## Verification limits and exact reviewed files
|
||||
|
||||
Direct no-filter Git blob comparisons verified all 16 unchanged peer files, including new untracked canvas modules; ordinary Git diff reports those untracked additions as absent when comparing against the peer, so it was not used as their byte proof. The three protected baseline comparisons and scoped diff whitespace validation passed. A second SHA256 readback found all 21 reviewed files unchanged. No compiler, tests, GUI, WSL, Git mutation or source edit was run by this reviewer. Assertions described above are verified test source, not freshly executed results. The peer's 729/Clippy pass and our earlier 707 pass remain tied to their original inputs; root's new combined results must identify the current union. The source-card layout, parent focus, actual canvas tasks and pan/B4 clipping/hit behavior still require the combined gates.
|
||||
|
||||
All paths below are relative to `crates/oa-desktop/src/`; these SHA256 values define this review's exact scope. The larger renderer file was read only for module-interface presence and is deliberately excluded from the accepted hash set because its dedicated worker/root owns that union.
|
||||
|
||||
| Path | Bytes | SHA256 |
|
||||
| --- | ---: | --- |
|
||||
| `app.rs` | 220191 | `6EB8B4BCD8F73D4BFDAACF30985D8292547440D65EAF49F6BB82AC158FBC46D3` |
|
||||
| `app/process_canvas.rs` | 30804 | `5A913BFA1E9E08D3A8AF314D2A883D55D1F1EED30E288546946F77C9F61CEB9C` |
|
||||
| `behavior/canvas_workspace.rs` | 7758 | `9303ED4F74EE0317E4ABEAB134F7C31F50EAC5EB5D85940D771CD3AD595258FA` |
|
||||
| `behavior/canvas_workspace/control.rs` | 7804 | `2B8D97B8C0E3FF1B1A2C32187B1D0EB25EDF39BA582B5FB3303BC880F674F7BA` |
|
||||
| `behavior/canvas_workspace/focus.rs` | 8943 | `CC58CB3838F546150D22FE4DD83A3FFD52504C279FEBAEB8D0B4A6A3B8A73950` |
|
||||
| `behavior/canvas_workspace/tests.rs` | 27020 | `D84D022574A6F1D69F203D9383502227FB9FC23605C6FB48779CA9F09BEE546D` |
|
||||
| `behavior/canvas_workspace/viewport.rs` | 14436 | `AF1C7C1E802D1207EE6C7BD66B703811A250D7B56511512AF6C0945C776E68A7` |
|
||||
| `behavior/contextual_source_tests.rs` | 49518 | `044BD412DC45FFE440B1E251993946942DC3759DCB2D5DFE71BD8D6EF61CBCC7` |
|
||||
| `behavior/scenario.rs` | 152266 | `CE99D5902F16629BE84F3F10F45CF632EEF037D22401E54482057552D983D017` |
|
||||
| `behavior/scenario/workspace.rs` | 25995 | `29F3BC14AA19AD110E60BAAAEA644A53DAABC0CFA7C8CA4542E9D5A2C11CB9D0` |
|
||||
| `behavior/scenario/workspace/tests.rs` | 80083 | `57649638930E4B47DCBC3F9F6A2F991DA4393DBC1EF243BBC09E01D40932F5BA` |
|
||||
| `behavior/topology.rs` | 84212 | `26E24AD0F781A3304C46299B3BD906917E57FAC262E7F52FBA4ADC80A4358EC5` |
|
||||
| `behavior/topology/contextual.rs` | 28432 | `5D0AC5B3E258FC70987A00EDE1E603681D99746596F2FCC46AD2B3667D65203A` |
|
||||
| `behavior/topology/direct.rs` | 36121 | `FA9CEAE78174D09904A9EBF90B0792AE9A2D8D26E3BE56A3891D5DE5345E49D0` |
|
||||
| `behavior/topology/direct_tests.rs` | 51170 | `EDCCFE3CE8B224065A3540AA53C6340F13DB0CECD48ECC0A3904CD832A483964` |
|
||||
| `behavior/topology/layout_tests.rs` | 36842 | `9031B2B2BD7AF47ADA3F1811C71F3EE372B07A545FB59C319A99D625F2D29EB6` |
|
||||
| `behavior/topology/view.rs` | 23136 | `B2D502C11426BFFB8763515F2A56AA4C9F8E203BB36D65EB34F81C123FC406A6` |
|
||||
| `guide.rs` | 116479 | `57DC7FFAD9FE07392C95F4493BE1931C48E2FB417ABB0F3B64B8007006AAFC7A` |
|
||||
| `behavior/creation.rs` | 29301 | `438A245EAD397060767C790C51A8F21CD5021342627999DEB4695CF3BD22091A` |
|
||||
| `behavior/source_draft.rs` | 39485 | `85D543CC288E48B4C13001E4E386B5B54D9B1E349A5AC31E4E307572F2D8FB3C` |
|
||||
| `tests.rs` | 75189 | `686DE3E6896CB322E3BA642FE5EF04F570E2FA04EB1956E08E958F3378FA4F1C` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"schema": "arcadia.closed-source-review-archive.v1",
|
||||
"captured_utc": "2026-09-11T19:46:50.7865645Z",
|
||||
"commit": "a57c334b302eb13352b4bb65df1c75d94d002348",
|
||||
"scope": "Seven immutable source-review payloads; no combined execution acceptance",
|
||||
"payload_count": 7,
|
||||
"payload_bytes": 147501.0,
|
||||
"files": [
|
||||
{
|
||||
"original": ".local/canvas-prerequisite-union-report.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-prerequisite-union-report.md",
|
||||
"destination": "canvas-prerequisite-union-report.md",
|
||||
"bytes": 7652,
|
||||
"original_last_write_utc": "2026-09-11T19:31:50.7208334Z",
|
||||
"original_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9",
|
||||
"copy_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-renderer-union-report.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-renderer-union-report.md",
|
||||
"destination": "canvas-renderer-union-report.md",
|
||||
"bytes": 4147,
|
||||
"original_last_write_utc": "2026-09-11T19:36:57.0468036Z",
|
||||
"original_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B",
|
||||
"copy_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-pan-independent-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-pan-independent-review.md",
|
||||
"destination": "canvas-pan-independent-review.md",
|
||||
"bytes": 5592,
|
||||
"original_last_write_utc": "2026-09-11T19:32:27.3870393Z",
|
||||
"original_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D",
|
||||
"copy_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-root-prerequisite-final-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-root-prerequisite-final-review.md",
|
||||
"destination": "canvas-root-prerequisite-final-review.md",
|
||||
"bytes": 8560,
|
||||
"original_last_write_utc": "2026-09-11T19:43:20.6734961Z",
|
||||
"original_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48",
|
||||
"copy_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-renderer-final-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-renderer-final-review.md",
|
||||
"destination": "canvas-renderer-final-review.md",
|
||||
"bytes": 5870,
|
||||
"original_last_write_utc": "2026-09-11T19:43:56.3470460Z",
|
||||
"original_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4",
|
||||
"copy_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4"
|
||||
},
|
||||
{
|
||||
"original": ".local/next-integration-865483b-review.md",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\next-integration-865483b-review.md",
|
||||
"destination": "next-integration-865483b-review.md",
|
||||
"bytes": 13821,
|
||||
"original_last_write_utc": "2026-09-11T19:13:59.2031630Z",
|
||||
"original_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642",
|
||||
"copy_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642"
|
||||
},
|
||||
{
|
||||
"original": ".local/canvas-union-windows-20260911/inputs.json",
|
||||
"original_absolute": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\canvas-union-windows-20260911\\inputs.json",
|
||||
"destination": "inputs.json",
|
||||
"bytes": 101859,
|
||||
"original_last_write_utc": "2026-09-11T19:42:04.9149380Z",
|
||||
"original_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"copy_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
# Next integration preflight against 865483b
|
||||
|
||||
Codex-ytr83r /root/wsl_startup_review, 2026-09-11. Private read-only planning; production baseline is `865483b4c66361a01fe0d9e731859220bafb4136` on `codex/release1-ytr83r`. Root's current packaged native acceptance remains pending. No integration, build, compiler, GUI, WSL, Git mutation, or Gitea write occurred in this task.
|
||||
|
||||
**Recommendation:** retain the current preview checkpoint. Leave navigation pending its four observed native corrections; prepare canvas only as a dedicated chooser/parent-focus + camera + B4/header + child-work union. Neither peer branch is a safe whole-file replacement for our production source. Issue 3 allocation drag is a separate unpublished implementation, not part of the accepted canvas source.
|
||||
|
||||
## Published source and actual readiness
|
||||
|
||||
| Contribution | Exact published source | Evidence and remaining limit |
|
||||
| --- | --- | --- |
|
||||
| Canvas/palette, peer 956atf | [`a31b40d0f8551ed87c8cd66250b9afb4809b3a79`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/a31b40d0f8551ed87c8cd66250b9afb4809b3a79), branch `codex/release1-956atf` | [Issue 2 comment 475](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-475): final 729 passed, no failed/ignored/filtered; strict desktop all-target Clippy passed at 18:38:52 UTC, 518 inputs unchanged. This supersedes the older report's failed obsolete-variant Clippy result. No final matching native acceptance was published in the inspected evidence; our B4 renderer was outside that peer union. |
|
||||
| Definition/References/Back, peer 9e8r6t | [`36db9356cce3c7614ae37eec9fe17678ca210afe`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/36db9356cce3c7614ae37eec9fe17678ca210afe); evidence/build tip [`8e00d3ce4f71eb8d6d1256ab283819c88c0fe803`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/8e00d3ce4f71eb8d6d1256ab283819c88c0fe803), branch `codex/release1-9e8r6t` | Earlier full desktop 737 passed + 1 ignored predates final replay/Escape changes. Final source had focused 181 passed + 1 ignored and strict desktop Clippy, not a repeated full 737. Matching GUI SHA256 `DF7126B8FC14C90985C2A0C55A640990F990E487D95A33F194AA7238754CEABD`, 113,197,056 bytes. Native work then found four unresolved defects. |
|
||||
| Allocation drag, issue 3 | No implementation commit published in inspected issue/branch evidence | [Comment 472](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3#issuecomment-472): private planner review found no remainder; three additional tests were being added, unexecuted. Private App/diagram candidate is outside the canvas freeze. Contract is published at `88c17a448fcecc9cf9cfb108d0f4315f925d4851`; this does not establish compiled/native gesture acceptance. |
|
||||
|
||||
Navigation [comment 479](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6#issuecomment-479) established useful native F12/References/Back, dirty-draft choice, source Undo, Find focus suppression and minimum-window editing, but also editor vertical movement when diagnostics clear/refresh and an empty 300 px pane after Close then Back. Latest [comment 482, 19:03:17 UTC](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6#issuecomment-482) adds Find-only requiring two Escapes and Alt+Space → Escape → neutral header click inserting a delayed space at the old caret. These corrections were still unpublished. The latter defect appeared only after the settled sequence; immediate screenshots were misleading. Visible Save All followed by the complete Project/ID and four reachable/four physical-file oracle passed after edits were restored, which does not clear these interaction defects. Its initial 52-payload archive was reported, not independently replayed here.
|
||||
|
||||
Pinned source reports: [canvas gate](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/a31b40d0f8551ed87c8cd66250b9afb4809b3a79/harness/release-behavior-canvas-windows-gate-20260911.md), [navigation integrated evaluation](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/8e00d3ce4f71eb8d6d1256ab283819c88c0fe803/harness/release-text-navigation-integrated-20260911.md). Earlier component counts, focused runs and full runs overlap and must not be added. The 100-module/10,000-use performance result measures worker capture/query, not GUI latency or model validity. Initial core 445 + 1 ignored preceded the corrected Unicode fixtures; those had a separate 12-test validation.
|
||||
|
||||
## Exact source boundaries
|
||||
|
||||
Canvas's 20 changed crate paths below are the exact complete-tree delta `638d06c4e33a1f54203b234d0b49c3ecf551d27d → a31b40d0...`. Intermediate implementation/corrections are `6969c825`, `06ec9621`, `ff017548`, `82fd0acf`, `abffa4b3`, `88c17a44`, then `a31b40d0`. Older candidate branch tip `ca19ad27` is superseded.
|
||||
|
||||
- `crates/oa-desktop/src/app.rs`
|
||||
- `crates/oa-desktop/src/app/process_canvas.rs`
|
||||
- `crates/oa-desktop/src/app/reference_authoring.rs`
|
||||
- `crates/oa-desktop/src/behavior.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/control.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/focus.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs`
|
||||
- `crates/oa-desktop/src/behavior/contextual_source_tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario/workspace.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/contextual.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/direct.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/direct_tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/layout_tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/view.rs`
|
||||
- `crates/oa-desktop/src/guide.rs`
|
||||
|
||||
This delta is **not self-contained on 865483b**. Its base already contains reference chooser, form-session and parent-focus plumbing. Local readback confirms these four entrypoints absent: `app/reference_authoring.rs`, `behavior/form_session.rs`, `behavior/scenario/focus.rs`, `forms/reference_chooser.rs`. The prerequisite closure also spans chooser adapter/trigger/focus/widget/tests; reference-authoring tests/mounted tests; forms/data_fields; scenario workspace; semantic-review row/view/batch interfaces and tests; boxed workflow events; App review-navigation bindings. Canvas's reference overlay and keyboard ownership call those APIs directly. This is a concrete dependency family, not a claim that a minimal cherry-pick/apply closure has been validated.
|
||||
|
||||
Do not wholesale import the base: it also includes unrelated analysis_numeric/core Cargo/lock changes. More seriously, peer `behavior/creation.rs` and `behavior/source_draft.rs` retain old pre-b2c blobs (`6940c7b9...`, `7c5dced5...`), whereas our corrected b2c versions are `d30281a4...`, `a8a39041...`. Replacing them would lose staged-owner/minimum-source-card corrections. Merge any needed chooser/session changes into our corrected files.
|
||||
|
||||
Navigation has these 18 exact crate paths relative to contextual `b2c3e54001fc19a05423bcd5808ceea78d13ff59`:
|
||||
|
||||
- `crates/oa-core/src/language.rs`
|
||||
- `crates/oa-core/src/language_outline_tests.rs`
|
||||
- `crates/oa-desktop/src/app.rs`
|
||||
- `crates/oa-desktop/src/app/hosted.rs`
|
||||
- `crates/oa-desktop/src/app/source_symbols.rs`
|
||||
- `crates/oa-desktop/src/app/source_symbols/tests.rs`
|
||||
- `crates/oa-desktop/src/guide.rs`
|
||||
- `crates/oa-desktop/src/source_navigation.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/controller.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/interaction.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/interaction/tests.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/query.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/query_tests.rs`
|
||||
- `crates/oa-desktop/src/source_navigation/symbols.rs`
|
||||
- `crates/oa-desktop/src/source_workspace.rs`
|
||||
- `crates/oa-desktop/src/tests.rs`
|
||||
- `crates/oa-desktop/src/views.rs`
|
||||
- `crates/oa-desktop/src/views/source_symbols.rs`
|
||||
|
||||
Also preserve its language guide source/generated HTML pair, `docs/language.rst` and `docs/language.html`. The intermediate contextual publication `fb24def2...` has the same crate tree as b2c (`b77bddcf03b1869add8da06d14f94892b69a6b21`). Navigation source and evidence tip share crate tree `2638806948b29322f251777e6bb3d8f3f178ea1d`. Root Cargo.toml/Cargo.lock remain unchanged there; this nevertheless changes core language implementation, not just desktop. No inherited evidence archive or unrelated live record needs wholesale copying.
|
||||
|
||||
## Concrete merge risks
|
||||
|
||||
| Our retained file/behavior | Required union |
|
||||
| --- | --- |
|
||||
| `app.rs` child-work correction | Both peer `add_element` bodies are identical to each other and still pre-fix: neither transfers leaf responsibility relations nor validates the placement candidate with execute_batch. Keep our allocation/role target transfer preserving relation IDs, unambiguous later-child inheritance and atomic candidate execution. Merge other App hunks around it. |
|
||||
| `tests.rs` | Preserve all three child_work regressions. Navigation edits older source-find/reveal tests; canvas's 20-path delta does not touch this file. Whole peer-file replacement would drop our child-work tests. |
|
||||
| `behavior.rs` | Canvas adds camera_pan and pan/scale drawing. Our runtime lifecycle rendering and bounded header text are absent from that peer file. Keep both and adapt clip rectangle plus clipped child-text transform to pan AND scale; clipped frames reset their transform. |
|
||||
| `behavior/scenario_lifecycle/render_tests.rs` and renderer | Peer tree has no B4 render_tests and only its earlier projection module. Preserve our markers/selection/visible lifetimes. Create-label clipped child frames and hit coordinates need the same pan/scale mapping as the scene; scale-only tests cannot prove this union. |
|
||||
|
||||
Navigation itself does not change behavior.rs in its 18-path delta, so copying its older branch version would introduce needless B4 loss. Its App raw-input observer must compose with canvas/reference/scenario focus roots: observe raw events before nested widget/overlay capture while preserving current owner/revision/mount guards and chooser priority. A winner-takes-all App::view replacement loses one contribution. Captured Escape, modal ownership, source editor focus and canvas shortcuts are the highest-risk shared event routes. Preserve our lifecycle/contextual guide text when merging the independent canvas/navigation help additions.
|
||||
|
||||
Issue 3's later semantic drag must remain separate from transient camera movement. The published contract distinguishes layout-only moves with the same effective performer from explicit semantic incoming/reassign/outgoing drops with clean press/release admission and complete candidate validation. Mixed convergence and unsupported role exits refuse. Its private implementation cannot be inferred from branch names or filesystem paths.
|
||||
|
||||
## Next bounded step and genuinely new checks
|
||||
|
||||
Root has chosen to publish current evidence and defer integration. At the next source checkpoint, first pin the peer's ready canvas/chooser prerequisite closure and perform a dedicated hunk-level B4/header/child-work union, excluding analysis_numeric and preserving b2c form fixes. Obtain an independently reviewed diff before building. Wait for peer 9e8r6t's published four native fixes and matching results before adding navigation; compose the App observer/focus routes in that separate step. Do not import private issue 3 work or repeat its unexecuted-test claims as passes.
|
||||
|
||||
New union checks have concrete purposes:
|
||||
|
||||
- Run the retained child-work three tests, contextual-source and parent-focus/chooser tests to detect lost responsibility/source/session behavior; retain actual mounted App roots, overlay handling, message draining before returned Tasks, and stale-task assertions.
|
||||
- Add/run pan-aware B4 and header pixel/hit checks at nonzero pan and relevant zoom/minimum-window sizes, including Create capsule labels and clipped viewport edges. Test pan + scale on both clip and child text, not only scene geometry.
|
||||
- Run actual mounted canvas Fit/Tab/F6/palette/dirty/modal ownership cases; the peer's forced 1100×420 overflow case is not itself native 1100×700 acceptance.
|
||||
- For navigation union, exercise raw editor/Find/results/chooser/modal Escape and delayed Alt+Space input after the peer correction; dirty/stale query/reveal and explicit candidate selection must leave source/model history coherent.
|
||||
- A frozen combined desktop needs its own full all-target tests, strict desktop Clippy and matching packaged native workflow evidence. Prior 707/729/737 counts prove their recorded inputs only. Adding language.rs also requires the relevant core language/Unicode and core/server/CLI gates; do not relabel immutable older server ABA87A5F as a newly built navigation server.
|
||||
|
||||
## Evidence limits
|
||||
|
||||
This review used Gitea issue 3 through comment 472, issue 2 through 475, issue 6 through 482, published branch/commit metadata, exact blob contents and complete recursive crates subtrees (not truncated repository-wide inventories), local read-only Git/source comparisons, and relevant wiki pages (BehaviorCanvasPhase120260911, ScenarioFocus20260911, Authoring20260911). Older wiki execution-pending wording is historical; later issue results are scoped above. Peer execution is attributed, not independently repeated. No apply-check or compiler validation of a merged candidate was performed; current peer objects were not fetched into this checkout. Different filesystem views are established by peer coordination comments 473/477; matching Windows paths do not imply shared binaries, profiles or private working trees.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"schema": "arcadia.closed-source-review-readback.v1",
|
||||
"verified_utc": "2026-09-11T19:46:51.7527753Z",
|
||||
"manifest_sha256": "EDAA07071D15969009594A8C7A80E36590859D61383A6B1FCAB6F05F694F99F3",
|
||||
"payload_count": 7,
|
||||
"discrepancy_count": 0,
|
||||
"files": [
|
||||
{
|
||||
"destination": "canvas-prerequisite-union-report.md",
|
||||
"original_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9",
|
||||
"copy_sha256": "E1F110B02B8068683153384CD4020483A77243FA4D7E61A24B76304BE7D7FEB9",
|
||||
"original_bytes": 7652,
|
||||
"copy_bytes": 7652,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-renderer-union-report.md",
|
||||
"original_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B",
|
||||
"copy_sha256": "87BD3BC6A34F652C2988ED0127B81E59790C47C82CB9429420DCA87828B0B30B",
|
||||
"original_bytes": 4147,
|
||||
"copy_bytes": 4147,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-pan-independent-review.md",
|
||||
"original_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D",
|
||||
"copy_sha256": "DDB4FF341ED4CC2D86351D8D2243E357449708CF38F32CC93A2B76C3A86CAB0D",
|
||||
"original_bytes": 5592,
|
||||
"copy_bytes": 5592,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-root-prerequisite-final-review.md",
|
||||
"original_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48",
|
||||
"copy_sha256": "EBFF9109DA0949A62E1BEDA0140C36EB76FB36A537C77263F02210CCA7FC0A48",
|
||||
"original_bytes": 8560,
|
||||
"copy_bytes": 8560,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "canvas-renderer-final-review.md",
|
||||
"original_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4",
|
||||
"copy_sha256": "178697DD6E65F68CD9AEF863304C9053CAF72351ED62C469846D26C9A1AF2EF4",
|
||||
"original_bytes": 5870,
|
||||
"copy_bytes": 5870,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "next-integration-865483b-review.md",
|
||||
"original_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642",
|
||||
"copy_sha256": "DBB5506AD2687BD108AFE7D392F8C1A4AA4B77EAACB6889147FE3F093E223642",
|
||||
"original_bytes": 13821,
|
||||
"copy_bytes": 13821,
|
||||
"matches_frozen_manifest": true
|
||||
},
|
||||
{
|
||||
"destination": "inputs.json",
|
||||
"original_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"copy_sha256": "47FEC9CADC0E7FAE99D789376C111C02EEAAB7FAC3740DE48174A06F61FE16B3",
|
||||
"original_bytes": 101859,
|
||||
"copy_bytes": 101859,
|
||||
"matches_frozen_manifest": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Standalone CLI package evidence
|
||||
|
||||
Exact frozen author and independent reviewer evidence for the Windows
|
||||
`ArcadiaCLI-0.2.0-preview.authoring.20260911` artifact. `hashes.json` records
|
||||
original private paths, lengths and hashes; preserve these bytes through Git.
|
||||
|
||||
The original host-specific reviewer script expects its recorded `.local` layout,
|
||||
source snapshot and fresh disposable outputs. Restore that layout to reproduce
|
||||
it; running the archived file in this directory is not a portable test command.
|
||||
The production packaging tests remain under `scripts/`.
|
||||
|
||||
The author report records 15 inert checks, 13 Python tests, default-preserving
|
||||
notice selection and strict package assembly. Independent review verifies source
|
||||
equivalence/defaults, all 114 archive files and 156 assertions across 10 clean
|
||||
runtime invocations. Neither completed the wider OA/SA product or Linux acceptance.
|
||||
@@ -0,0 +1,5 @@
|
||||
.............
|
||||
----------------------------------------------------------------------
|
||||
Ran 13 tests in 1.252s
|
||||
|
||||
OK
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user