Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad1f5f33b | ||
|
|
9d7e311320 | ||
|
|
9cc8194d7b | ||
|
|
3ee5aa6809 | ||
|
|
ca19ad277a | ||
|
|
fb1da912c9 | ||
|
|
113fb8b7a9 | ||
|
|
e511c4153f |
@@ -1,24 +1,73 @@
|
||||
## Cooperation through Gitea MCP
|
||||
# Agent working agreement
|
||||
|
||||
Use Gitea MCP to coordinate with other agents working on this project.
|
||||
## Working method
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Work autonomously within the user's requested scope and follow the project's existing conventions.
|
||||
## User input
|
||||
|
||||
## Gitea and harness maintenance
|
||||
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.
|
||||
|
||||
Keep Gitea resources and files under `harness` directory organized, accurate, and useful for other agents.
|
||||
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.
|
||||
|
||||
* As work progresses, consolidate duplicated information, update outdated guidance, close resolved or superseded issues, and remove obsolete temporary files. Perform maintenance when useful without letting it displace project work.
|
||||
* Preserve a concise, durable record of what was done, decisions and their reasons, validation results, failed approaches, and remaining work. Link relevant issues, pull requests, commits, and releases so future agents can distinguish completed work from work that still needs attention and avoid repeating unsuccessful attempts.
|
||||
* Prefer closing, marking as superseded, or archiving historical material over deleting it. Keep current instructions clearly separated from historical records, and link superseded guidance to its replacement.
|
||||
* Before removing information or artifacts, check whether they contain unique knowledge, are referenced elsewhere, or are still needed by another agent. Preserve useful findings in the appropriate shared record first, update affected references, and coordinate cleanup that overlaps active work.
|
||||
* Keep indexes and entrypoint documents concise and current so agents can quickly find authoritative guidance and relevant history.
|
||||
## 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
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
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.
|
||||
|
||||
+38
-606
@@ -1,616 +1,49 @@
|
||||
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.
|
||||
# Project goal
|
||||
|
||||
Your responsibility is not merely to write software. Your responsibility is to solve the underlying problem end-to-end.
|
||||
## Objective
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Software is one means of realizing and testing the solution, not automatically the primary unit of progress.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
Do not weaken completion criteria merely because substantial time or context has been consumed.
|
||||
Apply these criteria to the scope requested by the user:
|
||||
|
||||
The project is complete only when all applicable conditions are satisfied:
|
||||
- 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 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.
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Only when the completion criteria are satisfied, provide a concise handoff covering:
|
||||
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.
|
||||
|
||||
- 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.
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
|
||||
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.
|
||||
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
|
||||
|
||||
<!-- 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.
|
||||
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
|
||||
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.
|
||||
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
|
||||
Remaining ARCADIA layers will be added in future releases.
|
||||
|
||||
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
|
||||
@@ -619,33 +52,32 @@ 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
|
||||
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
|
||||
|
||||
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 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)
|
||||
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 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.
|
||||
@@ -658,7 +90,6 @@ 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.
|
||||
@@ -672,8 +103,9 @@ 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
|
||||
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 -->
|
||||
|
||||
@@ -319,6 +319,7 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
BehaviorControl(behavior::canvas_workspace::Intent),
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
CheckpointTick,
|
||||
@@ -453,6 +454,9 @@ pub struct App {
|
||||
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,
|
||||
@@ -556,6 +560,8 @@ impl App {
|
||||
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,
|
||||
@@ -1032,7 +1038,10 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base))
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base)),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1088,6 +1097,105 @@ 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;
|
||||
}
|
||||
@@ -1383,6 +1491,7 @@ impl App {
|
||||
}
|
||||
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(),
|
||||
@@ -1446,6 +1555,49 @@ 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();
|
||||
}
|
||||
@@ -1459,9 +1611,11 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
if self.behavior.scenario.zoom(scale) {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, 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()
|
||||
@@ -1583,13 +1737,22 @@ impl App {
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
);
|
||||
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,
|
||||
)
|
||||
};
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
|
||||
@@ -15,18 +15,15 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
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 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 edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
@@ -52,6 +49,47 @@ 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();
|
||||
}
|
||||
@@ -81,6 +119,7 @@ impl App {
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
@@ -118,14 +157,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before choosing a process tool.".into();
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
self.behavior.topology.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.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
@@ -149,7 +192,9 @@ impl App {
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior
|
||||
.topology
|
||||
.remember(context.owner, occurrence, target);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
|
||||
@@ -49,6 +49,10 @@ pub(super) struct State {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -14,9 +14,11 @@ use iced::{
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
pub(crate) mod canvas_workspace;
|
||||
#[cfg(test)]
|
||||
mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
// Projection acceptance precedes the coordinated scenario renderer integration.
|
||||
#[cfg(test)]
|
||||
@@ -39,6 +41,7 @@ mod scenario_scroll {
|
||||
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
|
||||
context: scenario::Context,
|
||||
area: scenario::ScrollArea,
|
||||
process: bool,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
@@ -46,43 +49,8 @@ mod scenario_scroll {
|
||||
content: content.into(),
|
||||
context,
|
||||
area,
|
||||
process: false,
|
||||
process,
|
||||
restore,
|
||||
reveal: None,
|
||||
})
|
||||
}
|
||||
pub(super) fn observe_canvas<'a, R: renderer::Renderer + 'a>(
|
||||
context: scenario::Context,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
reveal: Option<scenario::ScrollReveal>,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Observer {
|
||||
content: content.into(),
|
||||
context,
|
||||
area: scenario::ScrollArea::Canvas,
|
||||
process: false,
|
||||
restore,
|
||||
reveal,
|
||||
})
|
||||
}
|
||||
pub(super) fn observe_topology<'a>(
|
||||
context: topology::Context,
|
||||
area: scenario::ScrollArea,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
Element::new(Observer {
|
||||
content: content.into(),
|
||||
context: scenario::Context {
|
||||
document: context.document,
|
||||
scenario: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: context.epoch,
|
||||
},
|
||||
area,
|
||||
process: true,
|
||||
restore: None,
|
||||
reveal: None,
|
||||
})
|
||||
}
|
||||
struct Observer<'a, R = Renderer> {
|
||||
@@ -91,14 +59,12 @@ mod scenario_scroll {
|
||||
area: scenario::ScrollArea,
|
||||
process: bool,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
reveal: Option<scenario::ScrollReveal>,
|
||||
}
|
||||
type Report = Option<(bool, Id, Id, Vector)>;
|
||||
struct ObservedScroll {
|
||||
identity: (Id, Id),
|
||||
report: Report,
|
||||
restored: Option<Id>,
|
||||
revealed: Option<(Id, scenario::Slot)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Translation(Option<Vector>);
|
||||
@@ -137,41 +103,6 @@ mod scenario_scroll {
|
||||
self.applied = true;
|
||||
}
|
||||
}
|
||||
struct RevealTranslation {
|
||||
request: scenario::ScrollReveal,
|
||||
applied: bool,
|
||||
}
|
||||
impl widget::Operation for RevealTranslation {
|
||||
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
state: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
let reveal = self.request;
|
||||
if !reveal.y.is_finite() || !reveal.padding.is_finite() || bounds.height <= 0. {
|
||||
return;
|
||||
}
|
||||
let padding = reveal.padding.max(0.).min(bounds.height / 2.);
|
||||
let y = if reveal.y - padding < translation.y {
|
||||
(reveal.y - padding).max(0.)
|
||||
} else if reveal.y + padding > translation.y + bounds.height {
|
||||
(reveal.y + padding - bounds.height).max(0.)
|
||||
} else {
|
||||
translation.y
|
||||
};
|
||||
// Preserve horizontal position and reveal only once per invocation
|
||||
// and explicit destination. Later ordinary scrolling stays ordinary.
|
||||
state.scroll_to(widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(y),
|
||||
});
|
||||
self.applied = true;
|
||||
}
|
||||
}
|
||||
impl<R: renderer::Renderer> Observer<'_, R> {
|
||||
fn report(
|
||||
&mut self,
|
||||
@@ -199,27 +130,6 @@ mod scenario_scroll {
|
||||
);
|
||||
operation.applied.then_some(request.token)
|
||||
});
|
||||
if let Some(reveal) = self.reveal.filter(|request| {
|
||||
!self.process
|
||||
&& receipt.is_none()
|
||||
&& tree.state.downcast_ref::<ObservedScroll>().revealed
|
||||
!= Some((request.token, request.slot))
|
||||
}) {
|
||||
let mut operation = RevealTranslation {
|
||||
request: reveal,
|
||||
applied: false,
|
||||
};
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&mut operation,
|
||||
);
|
||||
if operation.applied {
|
||||
tree.state.downcast_mut::<ObservedScroll>().revealed =
|
||||
Some((reveal.token, reveal.slot));
|
||||
}
|
||||
}
|
||||
let mut offset = Translation::default();
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
@@ -280,7 +190,6 @@ mod scenario_scroll {
|
||||
identity: (self.context.document, self.context.scenario),
|
||||
report: None,
|
||||
restored: None,
|
||||
revealed: None,
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
@@ -414,7 +323,7 @@ mod scenario_scroll {
|
||||
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
|
||||
Observer {
|
||||
context,
|
||||
area: scenario::ScrollArea::Canvas,
|
||||
area: scenario::ScrollArea::Page,
|
||||
process: false,
|
||||
restore: None,
|
||||
content: scrollable(iced::widget::Space::new().width(200.).height(height))
|
||||
@@ -484,7 +393,7 @@ mod scenario_scroll {
|
||||
std::time::Instant::now(),
|
||||
))
|
||||
};
|
||||
for area in [ScrollArea::Canvas, ScrollArea::Page] {
|
||||
for area in [ScrollArea::Page] {
|
||||
for returned_height in [600., 250.] {
|
||||
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
@@ -1233,6 +1142,7 @@ struct Scene {
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
scroll_offset: iced::Vector,
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
@@ -1287,6 +1197,7 @@ impl Scene {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(700., 200.),
|
||||
@@ -1949,9 +1860,12 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
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,
|
||||
)
|
||||
});
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if !visible {
|
||||
@@ -1978,7 +1892,7 @@ impl canvas::Program<Message> for Scene {
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let CanvasTool::Add(tool) = tool {
|
||||
if !tool.canvas_occurrence() {
|
||||
if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline {
|
||||
return None;
|
||||
}
|
||||
state.press = Some(SequencePress::Occurrence {
|
||||
@@ -1988,10 +1902,18 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
|
||||
if let Some(occurrence) = self
|
||||
.hit(point)
|
||||
.filter(|id| self.rows.contains_key(id))
|
||||
.or_else(|| {
|
||||
self.reorder_handle()
|
||||
.filter(|r| r.contains(point))
|
||||
.map(|_| self.selected)
|
||||
})
|
||||
{
|
||||
state.press = Some(SequencePress::Reorder {
|
||||
occurrence: self.selected,
|
||||
parent: self.rows[&self.selected].0,
|
||||
occurrence,
|
||||
parent: self.rows[&occurrence].0,
|
||||
start: cursor.position()? - self.scroll_offset,
|
||||
moved: false,
|
||||
});
|
||||
@@ -2066,6 +1988,18 @@ impl canvas::Program<Message> for Scene {
|
||||
);
|
||||
}
|
||||
}
|
||||
SequencePress::Reorder {
|
||||
occurrence,
|
||||
moved: false,
|
||||
..
|
||||
} => {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, occurrence,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
SequencePress::Reorder {
|
||||
occurrence,
|
||||
parent,
|
||||
@@ -2103,6 +2037,7 @@ impl canvas::Program<Message> for Scene {
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
f.translate(self.camera_pan);
|
||||
f.scale(self.scale);
|
||||
for b in &self.boxes {
|
||||
let outline = matches!(
|
||||
@@ -2214,9 +2149,12 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable && state.context == Some(context) && state.tool == tool;
|
||||
let point = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let point = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let source = active.then_some(state.source).flatten();
|
||||
let reorder = active.then_some(state.press).flatten().and_then(|press| {
|
||||
if let SequencePress::Reorder { parent, .. } = press {
|
||||
@@ -2261,6 +2199,18 @@ impl canvas::Program<Message> for Scene {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if reorder.is_some()
|
||||
&& slot.is_none()
|
||||
&& let Some(point) = point
|
||||
{
|
||||
f.fill_text(Text {
|
||||
content: "Other operand: move stays in the current sequence".into(),
|
||||
position: Point::new(point.x + 8., point.y + 12.),
|
||||
size: (11. / self.scale).into(),
|
||||
color: Color::from_rgb(0.75, 0.2, 0.15),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(slot) = slot {
|
||||
f.stroke(
|
||||
&Path::line(
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! 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 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 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;
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 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))
|
||||
) {
|
||||
if !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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
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 new(app: &mut App) -> 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");
|
||||
let mut mounted = Self {
|
||||
renderer,
|
||||
cache: Cache::new(),
|
||||
operations: 0,
|
||||
};
|
||||
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();
|
||||
self.renderer.screenshot(
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ pub enum CanvasEvent {
|
||||
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),
|
||||
@@ -141,6 +142,7 @@ impl CanvasEvent {
|
||||
| Self::Viewport(context, _)
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
@@ -236,13 +238,6 @@ pub(super) struct ScrollRestore {
|
||||
pub token: Id,
|
||||
pub offset: iced::Vector,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ScrollReveal {
|
||||
pub token: Id,
|
||||
pub slot: Slot,
|
||||
pub y: f32,
|
||||
pub padding: f32,
|
||||
}
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct CallChoiceContext {
|
||||
document: Id,
|
||||
@@ -294,6 +289,18 @@ 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())
|
||||
}
|
||||
@@ -330,9 +337,31 @@ impl State {
|
||||
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
|
||||
}
|
||||
@@ -400,7 +429,7 @@ impl State {
|
||||
owner: frame.owner,
|
||||
token: Id::new_v4(),
|
||||
offsets: frame.offsets,
|
||||
pending: [true; 2],
|
||||
pending: [false, true],
|
||||
});
|
||||
}
|
||||
pub(super) fn scroll_restore(
|
||||
@@ -490,6 +519,7 @@ impl State {
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
// The former page scroll now belongs to a sibling outline shelf.
|
||||
self.scroll_offsets[0]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Scenario presentation. Layout/camera controls never construct model commands.
|
||||
use super::super::scenario_scroll;
|
||||
use super::*;
|
||||
use iced::widget::{Space, column, keyed_column, sensor};
|
||||
use iced::widget::{Space, column, sensor};
|
||||
|
||||
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(super) struct Presentation {
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) focused: bool,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) pan: iced::Vector,
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
@@ -46,23 +47,19 @@ impl Presentation {
|
||||
}
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// This is the measured inner Scrollable, never a window-height estimate.
|
||||
// Leave room for the overlay scrollbars on both axes.
|
||||
// Fit uses the actual mounted viewport with a small visual gutter.
|
||||
Some(
|
||||
((viewport.width - 16.).max(1.) / scene.width.max(1.))
|
||||
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
|
||||
)
|
||||
}
|
||||
fn scale(&self, scene: Size) -> f32 {
|
||||
pub(super) fn scale(&self, scene: Size) -> f32 {
|
||||
if self.fit {
|
||||
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
|
||||
} else {
|
||||
self.zoom.unwrap_or(1.)
|
||||
}
|
||||
}
|
||||
fn fit_limited(&self, scene: Size) -> bool {
|
||||
self.fit && self.fit_scale(scene).is_some_and(|scale| scale < MIN_SCALE)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
@@ -78,258 +75,182 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let mut scene = Scene::sequence(p, owner, app.selected);
|
||||
let scale = presentation.scale(scene.size);
|
||||
let limited = presentation.fit_limited(scene.size);
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = presentation.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
// The outline is a sibling shelf, not an ancestor of this canvas.
|
||||
scene.scroll_offset = state.scroll_offset();
|
||||
scene.pending_placement = state.pending_placement(owner);
|
||||
let reveal = state.form_session().and_then(|token| {
|
||||
let (slot, _) = scene.pending_placement?;
|
||||
let marker = scene.slots.iter().find(|marker| marker.slot == slot)?;
|
||||
Some(ScrollReveal {
|
||||
token,
|
||||
slot,
|
||||
y: marker.y * scale,
|
||||
padding: (24. * scale).max(12.),
|
||||
})
|
||||
});
|
||||
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
|
||||
let canvas = canvas_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Canvas),
|
||||
reveal,
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
);
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
};
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
let canvas = container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select),
|
||||
)
|
||||
.key((context.document, owner))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
button(
|
||||
text(if presentation.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
super::super::canvas_workspace::Control::new(
|
||||
button(
|
||||
text(if presentation.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.padding(6)
|
||||
.on_press(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
scope,
|
||||
"Panels",
|
||||
keyboard,
|
||||
Some(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
camera
|
||||
)
|
||||
.padding(6)
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context)))
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
if state.can_back(p, context) {
|
||||
navigation = navigation.push(
|
||||
button(text("Back to caller").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
),
|
||||
);
|
||||
}
|
||||
let mut header = column![
|
||||
navigation,
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if presentation.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
|
||||
context,
|
||||
Shelf::Tools
|
||||
))),
|
||||
button(text("Selection").size(12))
|
||||
.padding(6)
|
||||
.style(if presentation.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
|
||||
context,
|
||||
Shelf::Selection
|
||||
))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center)
|
||||
.wrap(),
|
||||
text(if pending {
|
||||
"Pending form · the highlighted sequence position is retained".to_owned()
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
|
||||
"Start with a lifeline representing existing or explicitly new work / participants."
|
||||
.into()
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
|
||||
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
|
||||
} else {
|
||||
let guidance = match tool {
|
||||
CanvasTool::Message(_) => {
|
||||
"Click a source position, then click or drag to a target · Esc cancels"
|
||||
}
|
||||
CanvasTool::Add(Tool::Action | Tool::Observation) => {
|
||||
"Click a highlighted position · a lifeline click preselects that lifeline"
|
||||
}
|
||||
CanvasTool::Add(Tool::Reuse | Tool::Fragment) => {
|
||||
"Click a highlighted sequence position to open its form"
|
||||
}
|
||||
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder",
|
||||
};
|
||||
format!("{tool} · {guidance}")
|
||||
})
|
||||
.size(11)
|
||||
]
|
||||
.spacing(6);
|
||||
if !pending {
|
||||
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
|
||||
header = header.push(
|
||||
row![
|
||||
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
|
||||
button("Add first lifeline").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
|
||||
}))
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
|
||||
header = header.push(
|
||||
row![
|
||||
text("No occurrences yet").size(12),
|
||||
button("Add first message").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
|
||||
})),
|
||||
button("Add first action").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
|
||||
}))
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
}
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
));
|
||||
}
|
||||
if limited {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
|
||||
let header = column![
|
||||
navigation,
|
||||
super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
))),
|
||||
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(4);
|
||||
let entries = [
|
||||
("S", "Select / reorder occurrences", CanvasTool::Select),
|
||||
(
|
||||
"L",
|
||||
"Lifeline: click to choose represented work or participant",
|
||||
CanvasTool::Add(Tool::Lifeline),
|
||||
),
|
||||
(
|
||||
"C/R",
|
||||
"Call and correlated reply",
|
||||
CanvasTool::Message(MessageStyle::CallReply),
|
||||
),
|
||||
(
|
||||
"M",
|
||||
"Asynchronous message",
|
||||
CanvasTool::Message(MessageStyle::Asynchronous),
|
||||
),
|
||||
(
|
||||
"C",
|
||||
"Synchronous call",
|
||||
CanvasTool::Message(MessageStyle::Call),
|
||||
),
|
||||
(
|
||||
"R",
|
||||
"Reply to existing call",
|
||||
CanvasTool::Message(MessageStyle::Reply),
|
||||
),
|
||||
(
|
||||
"+L",
|
||||
"Create participant",
|
||||
CanvasTool::Message(MessageStyle::Create),
|
||||
),
|
||||
(
|
||||
"-L",
|
||||
"Delete participant",
|
||||
CanvasTool::Message(MessageStyle::Delete),
|
||||
),
|
||||
("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)),
|
||||
(
|
||||
"A",
|
||||
"Action: click a sequence position",
|
||||
CanvasTool::Add(Tool::Action),
|
||||
),
|
||||
(
|
||||
"St",
|
||||
"State observation: click a sequence position",
|
||||
CanvasTool::Add(Tool::Observation),
|
||||
),
|
||||
(
|
||||
"ref",
|
||||
"Reuse scenario: click a sequence position",
|
||||
CanvasTool::Add(Tool::Reuse),
|
||||
),
|
||||
(
|
||||
"alt",
|
||||
"Fragment: click a sequence position",
|
||||
CanvasTool::Add(Tool::Fragment),
|
||||
),
|
||||
];
|
||||
let palette = super::super::canvas_workspace::palette(
|
||||
scope,
|
||||
keyboard,
|
||||
camera,
|
||||
entries.into_iter().map(|(glyph, label, choice)| {
|
||||
(
|
||||
glyph,
|
||||
label,
|
||||
choice == tool,
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let mut details = column![
|
||||
text(format!("Selected: {}", p.named(app.selected))).size(12),
|
||||
button(text("Properties").size(12))
|
||||
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
|
||||
]
|
||||
.spacing(6);
|
||||
if referenced_scenario(p, owner, app.selected).is_some() {
|
||||
details = details.push(button(text("Open definition").size(12)).on_press_maybe(
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse(
|
||||
context,
|
||||
app.selected,
|
||||
))),
|
||||
));
|
||||
}
|
||||
let details = match presentation.shelf {
|
||||
Some(Shelf::Tools) => {
|
||||
let choose = move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool));
|
||||
let tool_picker: Element<'_, Message> = if clean {
|
||||
pick_list(
|
||||
vec![
|
||||
CanvasTool::Select,
|
||||
CanvasTool::Message(MessageStyle::CallReply),
|
||||
CanvasTool::Message(MessageStyle::Asynchronous),
|
||||
CanvasTool::Message(MessageStyle::Call),
|
||||
CanvasTool::Message(MessageStyle::Reply),
|
||||
CanvasTool::Message(MessageStyle::Create),
|
||||
CanvasTool::Message(MessageStyle::Delete),
|
||||
CanvasTool::Message(MessageStyle::Timer),
|
||||
CanvasTool::Add(Tool::Action),
|
||||
CanvasTool::Add(Tool::Observation),
|
||||
CanvasTool::Add(Tool::Reuse),
|
||||
CanvasTool::Add(Tool::Fragment),
|
||||
],
|
||||
Some(tool),
|
||||
choose,
|
||||
)
|
||||
.text_size(12)
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.into()
|
||||
} else {
|
||||
button(text(format!("{tool} · finish pending edits to change tools")).size(12))
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.into()
|
||||
};
|
||||
column![
|
||||
tool_picker,
|
||||
row![
|
||||
button(text("+ Lifeline").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(
|
||||
clean.then_some(event(Event::Start(owner, Tool::Lifeline)))
|
||||
),
|
||||
button(text("Scenario properties").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(clean.then_some(Message::EditProperties(owner))),
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
text(match viewpoint(p, owner) {
|
||||
ScenarioViewpoint::OperationalActivity =>
|
||||
"Activity scenario · lifelines represent activities",
|
||||
ScenarioViewpoint::Functional =>
|
||||
"Functional scenario · lifelines represent system functions",
|
||||
ScenarioViewpoint::ParticipantInteraction =>
|
||||
"Participant scenario · entities, actors or roles",
|
||||
ScenarioViewpoint::BoundaryInterface =>
|
||||
"Interface scenario · system and external actors",
|
||||
ScenarioViewpoint::MixedLegacy => "Mixed scenario · work or participants",
|
||||
})
|
||||
.size(11),
|
||||
]
|
||||
.spacing(6)
|
||||
}
|
||||
_ => {
|
||||
let mut details =
|
||||
column![text(format!("Selected: {}", p.named(app.selected))).size(12)].spacing(6);
|
||||
let mut navigation = row![].spacing(6);
|
||||
if referenced_scenario(p, owner, app.selected).is_some() {
|
||||
navigation = navigation.push(
|
||||
button(text("Open definition").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(clean.then_some(Message::ScenarioCanvas(
|
||||
CanvasEvent::OpenUse(context, app.selected),
|
||||
))),
|
||||
);
|
||||
}
|
||||
navigation = navigation.push(
|
||||
button(text("Properties").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected))),
|
||||
);
|
||||
details = details.push(navigation.wrap());
|
||||
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
|
||||
details.push(
|
||||
text("Use Tools to add lifelines, then draw messages or insert occurrences.")
|
||||
.size(12),
|
||||
)
|
||||
} else {
|
||||
sequence_rows(p, context, owner, details, clean)
|
||||
}
|
||||
}
|
||||
};
|
||||
let details = details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
details.into(),
|
||||
presentation.shelf.is_some(),
|
||||
pending,
|
||||
sequence_rows(p, context, owner, details, clean).into(),
|
||||
);
|
||||
let mut form_panel = editor_panel(
|
||||
Id::nil(),
|
||||
@@ -644,7 +565,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.into();
|
||||
}
|
||||
}
|
||||
workspace_layout(header.into(), details, canvas, form_panel, pending)
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
presentation.shelf.map(|_| details)
|
||||
};
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
@@ -658,44 +584,10 @@ fn sequence_action<'a>(
|
||||
.on_press_maybe(enabled.then_some(message))
|
||||
}
|
||||
|
||||
// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures
|
||||
// must remain in the tree: Column::push otherwise drops them and remounts siblings.
|
||||
fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: iced::Element<'a, Message, Theme, R>,
|
||||
details: iced::Element<'a, Message, Theme, R>,
|
||||
canvas: iced::Element<'a, Message, Theme, R>,
|
||||
form: iced::Element<'a, Message, Theme, R>,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
let panes = Column::from_vec(vec![
|
||||
container(canvas).width(Fill).height(Fill).into(),
|
||||
container(form)
|
||||
.width(Fill)
|
||||
.height(if pending {
|
||||
Fill
|
||||
} else {
|
||||
iced::Length::Fixed(0.)
|
||||
})
|
||||
.clip(true)
|
||||
.into(),
|
||||
])
|
||||
.spacing(if pending { 6 } else { 0 })
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
Column::from_vec(vec![header, details, panes.into()])
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
context: Context,
|
||||
restore: Option<ScrollRestore>,
|
||||
details: iced::Element<'a, Message, Theme, R>,
|
||||
open: bool,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
scenario_scroll::observe(
|
||||
context,
|
||||
@@ -704,45 +596,10 @@ fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
scrollable(details)
|
||||
.id("scenario-details")
|
||||
.width(Fill)
|
||||
.height(if open {
|
||||
if pending { 72. } else { 100. }
|
||||
} else {
|
||||
0.
|
||||
}),
|
||||
.height(Fill),
|
||||
)
|
||||
}
|
||||
|
||||
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
context: Context,
|
||||
restore: Option<ScrollRestore>,
|
||||
reveal: Option<ScrollReveal>,
|
||||
content: iced::Element<'a, Message, Theme, R>,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
container(
|
||||
sensor(scenario_scroll::observe_canvas(
|
||||
context,
|
||||
restore,
|
||||
reveal,
|
||||
scrollable(content)
|
||||
.id("scenario-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
))
|
||||
.key((context.document, context.scenario))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
session: Id,
|
||||
actions: iced::Element<'a, Message, Theme, R>,
|
||||
@@ -751,16 +608,13 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
container(
|
||||
column![
|
||||
container(actions).id("scenario-actions").width(Fill),
|
||||
keyed_column([(
|
||||
super::super::form_session::reset_on_change(
|
||||
session,
|
||||
scrollable(fields)
|
||||
.id("scenario-form")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
)])
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
.height(Fill),
|
||||
),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,9 +81,10 @@ pub struct State {
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
|
||||
return_to: Vec<direct::ReturnFrame>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
pub(crate) focused: bool,
|
||||
@@ -125,6 +126,18 @@ 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())
|
||||
}
|
||||
@@ -348,10 +361,12 @@ impl State {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn canvas_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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)
|
||||
@@ -970,6 +985,8 @@ struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
camera_pan: iced::Vector,
|
||||
pending_insertion: Option<Insertion>,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
ordered_paths: Vec<OrderedPath>,
|
||||
@@ -1302,6 +1319,8 @@ impl<'a> Graph<'a> {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
nodes,
|
||||
edges,
|
||||
ordered_paths,
|
||||
@@ -1401,6 +1420,7 @@ 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 {
|
||||
@@ -1616,7 +1636,10 @@ 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.scale, p.y / self.scale);
|
||||
let p = Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
self.nodes.iter().any(|n| n.rect.contains(p))
|
||||
|| self.ordered_paths.iter().any(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
|
||||
@@ -608,7 +608,11 @@ mod tests {
|
||||
"Authored endpoint is retained for explicit repair"
|
||||
);
|
||||
assert!(state.prepare_pending(&deleted).is_err());
|
||||
state.remember(owner, reference);
|
||||
state.remember(
|
||||
owner,
|
||||
reference,
|
||||
super::super::direct::referenced(&p, owner, reference).unwrap(),
|
||||
);
|
||||
assert_eq!(state.back(&deleted), Some((owner, reference)));
|
||||
assert!(state.selected_context.is_none());
|
||||
state.draft = None;
|
||||
|
||||
@@ -60,6 +60,7 @@ 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),
|
||||
}
|
||||
@@ -77,6 +78,7 @@ impl CanvasEvent {
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Camera(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
@@ -86,6 +88,19 @@ 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);
|
||||
@@ -138,6 +153,7 @@ 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;
|
||||
@@ -162,11 +178,21 @@ impl State {
|
||||
// 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.5, 2.));
|
||||
self.zoom = value.map(|v| v.clamp(0.05, 3.));
|
||||
}
|
||||
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
|
||||
@@ -188,24 +214,73 @@ impl State {
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
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 back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
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()
|
||||
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()
|
||||
});
|
||||
return Some((owner, occurrence));
|
||||
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));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded);
|
||||
let old = self.scale(graph.size, 500.);
|
||||
if let Some(next) = zoom {
|
||||
let size = self.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(size.width / 2., size.height / 2.),
|
||||
base: old,
|
||||
});
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.scale(graph.size, 500.);
|
||||
self.zoom(Some(fitted));
|
||||
self.pan = iced::Vector::ZERO;
|
||||
}
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if self
|
||||
@@ -575,9 +650,12 @@ impl Graph<'_> {
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
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 publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
@@ -730,6 +808,22 @@ 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;
|
||||
}
|
||||
@@ -738,7 +832,12 @@ impl Graph<'_> {
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
.map(|p| {
|
||||
Point::new(
|
||||
(p.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
@@ -853,12 +952,7 @@ impl Graph<'_> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
@@ -870,10 +964,25 @@ 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))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,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.5);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1);
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
|
||||
let mut all = Vec::new();
|
||||
@@ -1364,82 +1364,6 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,107 +66,7 @@ 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 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))),
|
||||
);
|
||||
}
|
||||
}
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -211,11 +111,6 @@ 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))]
|
||||
@@ -256,27 +151,79 @@ 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))),
|
||||
);
|
||||
}
|
||||
let diagram = {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
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)),
|
||||
))),
|
||||
);
|
||||
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),
|
||||
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(
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
@@ -285,121 +232,104 @@ 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)
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Tools))),
|
||||
button(text("Selection details").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Selection))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
.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);
|
||||
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(
|
||||
.spacing(6),
|
||||
super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
event(Event::Shelf(Shelf::Selection)),
|
||||
clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
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))),
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
))),
|
||||
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) {
|
||||
let title = if p.layer_of(owner) == Some(Layer::Oa) {
|
||||
"Add first activity contribution"
|
||||
} else {
|
||||
"Add first function contribution"
|
||||
};
|
||||
header = header.push(button(title).on_press_maybe(
|
||||
(!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then(
|
||||
|| {
|
||||
Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
})
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
};
|
||||
explicit_pane(
|
||||
header.into(),
|
||||
details,
|
||||
canvas.into(),
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
};
|
||||
]
|
||||
.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) {
|
||||
@@ -617,77 +547,37 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
form_panel = editor_panel(d.invocation, actions.into(), form.into());
|
||||
}
|
||||
}
|
||||
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()
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
state.shelf.map(|_| details)
|
||||
};
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
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![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
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),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Shared behavior canvas phase 1 — R1/R2/R3 source correction freeze
|
||||
|
||||
Codex-956atf `/root/behavior_canvas_resume`, 2026-09-11. Isolated `.local/behavior-canvas-worktree-20260911`, branch `codex/behavior-canvas-20260911`, base `e511c4153fb5bb484652434340e0e4c7000c93d0`. Correction lease: [issue 2 comment 377](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-377). This answers root's full independent correction review (`root harness/release-behavior-canvas-corrections-review-20260911.md`) and [comment 366](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-366).
|
||||
|
||||
**Source preparation only.** All 19 owned Rust paths pass scoped standalone rustfmt formatting/parse check and `git diff --check`; only ordinary CRLF normalization notices were emitted. No Cargo/rustc, build, renderer/test/native execution, installation, Git mutation or Linux/WSL/VPN work occurred. Root owns independent review, integration and coherent Windows acceptance. Linux remains paused by the user. Prior root passing gates establish other bytes, not this candidate. Previous reports remain unchanged historical records; their superseded correctness claims must be read with the independent R1/R2/R3 review.
|
||||
|
||||
## R1/R2: stamped toolbar admission
|
||||
|
||||
Production palette, Details, Panels, Canvas, Fit and zoom actions now carry `Message::BehaviorControl(Intent)`: the current document/owner/revision/camera lifetime, stable control name and boxed existing action. App validates the live clean input owner and exact lifetime before inspecting a narrow presentation-action whitelist. Only the semantic epoch interval accumulated during that still-valid camera lifetime can be normalized to the current context. The existing exact-context process/scenario adapters then handle the action. The earlier process-only unstamped epoch exception is removed. Domain authoring gestures retain their exact full-context guards; the envelope cannot dispatch arbitrary model/source commands.
|
||||
|
||||
Consequently a held camera drag's own ordered Pan can precede its same-batch Fit/tool/panel action in BOTH adapters. External Fit/tool/panel/bounds/caller/chooser/dirty/busy transitions terminate the lifetime and invalidate retained toolbar packets even when semantic context has returned to the same epoch. Reapplying an unchanged Fit or tool also ends the gesture. The existing held-pan Fit `pan == ZERO` assertions are preserved.
|
||||
|
||||
Control no longer emits an earlier press-time ControlFocus Task. Accepted pointer or keyboard activation requests focus after the action, stamped with its resulting lifetime, matching Iced's final-view action execution. Canvas activation requests the viewport directly. F6 entry/exit, Tab/Shift+Tab traversal, Enter/Space activation and Escape exit remain the defined owner-respecting policy. Stable control identity preserves a visible keyboard target through a tool change.
|
||||
|
||||
## R3: actual runtime UI and guarded focus passes
|
||||
|
||||
The mounted helper now retains `iced_runtime::user_interface::Cache` and uses the installed `UserInterface` API for input, layout, operations, nested overlays and drawing. It drains every App message from an input batch while collecting immediately ready Task actions, builds the resulting final App view, then executes actions. Output messages become the next update cycle; pending Task streams survive until completion. Widget Chain passes run synchronously against that same UI, including root and nested overlays. Unexpected native actions panic. Production WGPU initialization still uses `expect`; there are no missing-backend skips. No private gesture/focus state is seeded.
|
||||
|
||||
A presentation owner marker wraps the actual App root. Both Collect and its Apply pass independently check the current clean owner and exact camera lifetime before changing focus or palette scroll. This is independent of root's later scenario parent-focus marker. The test deliberately retaining Apply across ownership changes establishes the stronger retained-operation invariant; installed native Iced executes Chain passes synchronously, so no hypothetical native interleaving is claimed.
|
||||
|
||||
The picker regression uses public Save(true) and cancellation completion transitions. On Windows it holds/drops the unopened external picker Task, verifies busy admission, and injects SavePath(None); every widget Task is executed. It does not test a native picker dialog. The existing Linux in-app picker path is represented by CancelFile, but was not run and Linux acceptance remains user-deferred.
|
||||
|
||||
## Prepared regressions, all uncompiled and unexecuted
|
||||
|
||||
There are now **17 production App-root mounted tests plus two shared math/input tests**. All previous direct-authoring, staged source-child, form identity, geometry, exact source/snapshot and history assertions remain.
|
||||
|
||||
- Existing process/scenario held-middle tests retain Fit==ZERO, now require same-batch pointer activation to focus the resulting control without F6 and the following Tab to focus ZoomOut. Retained camera and toolbar packets are asserted separately across Fit, tool, Details, Panels and resize.
|
||||
- Existing caller-return tests retain camera restoration/old-delta checks and additionally reject actual pre-call Fit packets after A→B→Back.
|
||||
- `production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes` and its scenario counterpart capture actual Fit/tool/Details/Panels/Canvas packets. Real chooser open/Escape, property Field/RevertProperties, and picker cancellation cycles must change lifetime without changing semantic epoch. Replays must preserve presentation, focus, source and history; dirty/busy owners also reject packets while active.
|
||||
- `production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool` and its scenario counterpart explicitly require the last tool to start fully below an overflowing palette, then require keyboard reveal of the whole tool, Enter activation and changed visible focus-border pixels. Connect and Fragment activation are asserted through resulting domain presentation state.
|
||||
- The existing chooser/Review test now observes the newly focused nested search input, absent from root input geometry. Both delayed Collect and retained Apply must preserve it; a retained Apply stays inert after returning to the same clean canvas with a new lifetime. The actual Review field and scroll state must survive both delayed passes. The test states that retained Chain execution is deliberate.
|
||||
|
||||
Root filters: `canvas_workspace::tests`, `production_process_`, `production_scenario_`, `production_empty_scenario`, `production_canvas_keyboard`, then preserved direct process/scenario/source/raw/history and root parent-focus/chooser gates. These are prepared filters, not results. Pixel assertions establish only the tested visible focus borders when executed; transformed scene clipping, refusal text, native input/accessibility and all popup geometry still require their existing acceptance gates.
|
||||
|
||||
## Exact scope and later union
|
||||
|
||||
This correction changed these ten existing Rust paths relative to comment360:
|
||||
|
||||
- `crates/oa-desktop/src/app.rs`
|
||||
- `crates/oa-desktop/src/app/process_canvas.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/scenario/workspace.rs`
|
||||
- `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/view.rs`
|
||||
- `crates/oa-desktop/src/behavior/topology/layout_tests.rs`
|
||||
|
||||
The candidate's total union remains the 19 Rust paths below, plus this new versioned handoff. `app/reference_authoring.rs` still contains only the previously authorized read-only reference_input_active accessor relative to its isolated baseline; no new chooser changes were made. `behavior/form_session.rs` remains root's frozen verbatim helper with SHA256 `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61`, used by scenario session and process invocation. No new source/core/schema/REST or persisted geometry contract was introduced. Camera/control/focus changes are presentation only; existing constrained semantic commands remain unchanged.
|
||||
|
||||
Root must merge its I1/I2 Name session-versus-mount identity, current FormShown payload/sensor, Back/Use/Escape rearm, parent root marker and same-axis focus adjustment, five parent-focus regressions and scenario replacement-scroll regression. Preserve root's current dynamic Id::from(String), chooser Send/'static/compiler corrections and chooser fixes. Also preserve root checkpoint 3c9e6ee's boxed workflow Event::Semantic(Box<Event>), Event::semantic constructor and reference_authoring .as_ref() pattern handling. None of those root changes were imported here; do not overwrite root App/scenario/workspace wholesale.
|
||||
|
||||
B4 remains ytr83r-owned. Apply pan plus scale to both its clipping rectangles and clipped child text during union; preserve its renderer/lifecycle oracles. Root owns common Wiki/ledgers, commits, releases and all execution. Full phase1 acceptance and issue2 closure are not claimed. Persistent placement/lane arrangement, exact source/reopen coverage, wider process body and endpoint manipulation, fragment wrapping/operand transfer and other phase2 domain work remain outside this bounded correction.
|
||||
|
||||
## Frozen source fingerprints
|
||||
|
||||
Ordered manifest SHA256: `9EBEC63D5D8B2595FB3867E63E7C6A26C4B04DFA1C7644B840E6FA001A2F0692`. Canonical manifest bytes are each table path, one ASCII space, its uppercase SHA256 and one LF, in this order, UTF-8. These are raw isolated working-file hashes; root integration will produce a separate union identity.
|
||||
|
||||
| Path | SHA256 |
|
||||
| --- | --- |
|
||||
| `crates/oa-desktop/src/app.rs` | `F059158240FBD41491C65D7BDDE318B0089A6DFED2688A741172535154078236` |
|
||||
| `crates/oa-desktop/src/app/process_canvas.rs` | `8B80661A021E2F9F8E83BD9B0858D707F067C2E42CB180A3CDFA59A1467B3DF9` |
|
||||
| `crates/oa-desktop/src/app/reference_authoring.rs` | `CFD96F4A38754C5FDA1B176B342162B7BFA7BFE0DCAE2A8309953A40395D92BF` |
|
||||
| `crates/oa-desktop/src/behavior.rs` | `A35D03A1A63BB8FD6D2E5D394CC41AB93A6F306F05BDD89353C6B79503F92E64` |
|
||||
| `crates/oa-desktop/src/behavior/canvas_workspace.rs` | `096D667C4C8EE3732D52668A9562C665F2D9A192ABDA4E5487FCAD7621C8DBFD` |
|
||||
| `crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs` | `AF1C7C1E802D1207EE6C7BD66B703811A250D7B56511512AF6C0945C776E68A7` |
|
||||
| `crates/oa-desktop/src/behavior/canvas_workspace/control.rs` | `851AF1E9B155D6D0D079D9164C5616FADC33E961391A6604A43EABF5158957B3` |
|
||||
| `crates/oa-desktop/src/behavior/canvas_workspace/focus.rs` | `CC58CB3838F546150D22FE4DD83A3FFD52504C279FEBAEB8D0B4A6A3B8A73950` |
|
||||
| `crates/oa-desktop/src/behavior/canvas_workspace/tests.rs` | `0272F78DAA97FAB75E971F1548A3E224C2F2D0A4D9D603DB90DBEB9F6D673C1E` |
|
||||
| `crates/oa-desktop/src/behavior/form_session.rs` | `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61` |
|
||||
| `crates/oa-desktop/src/behavior/scenario.rs` | `59F859B246C3186AC5B156B46AEE3A0DDC38F79C1CF332C4FB03092214A43A93` |
|
||||
| `crates/oa-desktop/src/behavior/scenario/workspace.rs` | `FED77DDF304FD831CC163F6B8079BB6C17AC755BF141A5D30D5E64C04CBD9677` |
|
||||
| `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs` | `851772CE7C1DA0479DA7D6FB16EF17038653B43DB3B8EDF5E88605AAE4830C89` |
|
||||
| `crates/oa-desktop/src/behavior/topology.rs` | `5723E51A7DA4EB224D51C1659EBBFA4DC8B11557BC8BF70F6E3E1649BAD24449` |
|
||||
| `crates/oa-desktop/src/behavior/topology/view.rs` | `B2D502C11426BFFB8763515F2A56AA4C9F8E203BB36D65EB34F81C123FC406A6` |
|
||||
| `crates/oa-desktop/src/behavior/topology/direct.rs` | `6102219487A0717C66190153C435B5B05226D156C59C7EAAB649249ADEF73644` |
|
||||
| `crates/oa-desktop/src/behavior/topology/direct_tests.rs` | `D3CD44477BDEB31256BA52AA70383CEFC9805A8B172271D357884C95DDEF11D8` |
|
||||
| `crates/oa-desktop/src/behavior/topology/layout_tests.rs` | `39D9C28201EC1B044EAE848B09AB276FF7FCF9F69FE6B5E39A6863F56A0111CE` |
|
||||
| `crates/oa-desktop/src/behavior/topology/contextual.rs` | `5D0AC5B3E258FC70987A00EDE1E603681D99746596F2FCC46AD2B3667D65203A` |
|
||||
@@ -0,0 +1,58 @@
|
||||
# Canvas phase 1 — interrupted WIP handoff
|
||||
|
||||
Codex-956atf /root/behavior_audit. Implementation stopped immediately on root's user-requested reboot checkpoint, 2026-09-11. **Frozen; do not resume until root's post-reboot follow-up.**
|
||||
|
||||
Workspace: C:/Users/User/Documents/Git/ARCADIA-Studio/.local/behavior-canvas-worktree-20260911
|
||||
Branch assigned by root: codex/behavior-canvas-20260911
|
||||
Verified starting commit: c0e66f94e3412b5c3495e96f7934ae94116505c9
|
||||
|
||||
This is intentionally unfinished and **not compile-coherent**. No Cargo/rustc/rustfmt/native/Git mutation was run by this worker. Original checkout was not edited. Root owns WIP commit/push and reboot-safe confirmation. Coordination: issue #2 comment 261; ratified design is release-unified-behavior-canvas-design-20260911.md. User clarification #2/250 explicitly permits semantic sequence constraints.
|
||||
|
||||
## Exact Rust paths changed
|
||||
|
||||
New:
|
||||
|
||||
- crates/oa-desktop/src/behavior/canvas_workspace.rs
|
||||
- crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs
|
||||
|
||||
Modified:
|
||||
|
||||
- crates/oa-desktop/src/behavior.rs
|
||||
- crates/oa-desktop/src/behavior/topology.rs
|
||||
- crates/oa-desktop/src/behavior/topology/direct.rs
|
||||
- crates/oa-desktop/src/behavior/scenario.rs
|
||||
- crates/oa-desktop/src/behavior/scenario/workspace.rs
|
||||
|
||||
This new handoff is the only report written in the implementation worktree. No App, core, source grammar, REST, schema, architecture/state implementation, existing tests, ledgers or prerequisites files were edited. The earlier design report was written in the original checkout under its prior report-only lease; do not confuse that completed handoff with this interrupted implementation.
|
||||
|
||||
## Saved implementation so far
|
||||
|
||||
- New shared module defines Scope (document/owner/revision), relative Pan and cursor-anchored Zoom events, Camera change helper, a constant-width tooltip palette, and a stable header/canvas/palette host with an overlaid editor. It contains no model commands.
|
||||
- New generic Iced Viewport wrapper implements focusable state, ordered press-position retention, middle/Space-drag, focused arrow camera keys, wheel zoom, focus border, input capture, child rendering clipped to viewport, and delegated widget operations/overlays. No tests or compiler validated its API usage.
|
||||
- Behavior declares canvas_workspace. Scene and Graph have a default-zero camera_pan, frame translation and inverse-hit coordinate changes. Existing scroll_offset remains for transitional compatibility; current production views still set/use old scroll offsets.
|
||||
- Scenario Presentation and process State have a new transient pan. Both expose camera() using the shared relative-input helper. Process enter resets pan with owner/document.
|
||||
- Both CanvasEvent enums gained Camera(Context, Event), and context() covers it.
|
||||
- Scenario whole-glyph reorder starts a captured Reorder for a hit occurrence with a row, preserving current-parent slot validation. The current interrupted code selects an unmoved occurrence on release; this was being reviewed against existing press-selection expectations. No process free-placement behavior was added; process semantic SetSteps remains explicitly constrained.
|
||||
|
||||
## Known incomplete work / blockers
|
||||
|
||||
1. canvas_workspace.rs declares mod tests, but **canvas_workspace/tests.rs does not exist**. Create meaningful mounted tests, not an empty placeholder. No existing test assertions were weakened or changed.
|
||||
2. New Camera variants have **no App handlers**; exhaustive matches may fail. Add minimal guarded adapters in App ScenarioCanvas and app/process_canvas.rs, plus Focus/Traverse Tasks. Relative camera events need document/owner/revision validation and correct same-batch ordering; root modal/pending guards must remain intact.
|
||||
3. Production process/scenario view functions have **not** been migrated to Fill viewport, the new palette or the stable overlay host. They still use model-sized canvases/Scrollables and split forms. Thus the current source does not deliver phase 1.
|
||||
4. Pan fields are not yet passed from presentation state into Scene/Graph, and old Fit/manual zoom paths are not reconciled with camera pan. Need explicit Fit reset/recenter and no automatic refit after editor/selection changes.
|
||||
5. The new Viewport's CursorMoved branch stores the event position, but its subsequent local pointer choice can still prefer Iced's final batch cursor. Correct this before testing; actual event positions must win for ordered movement.
|
||||
6. Starting camera input should invalidate an in-progress semantic gesture even with zero pan distance. A planned CancelGesture camera intent / epoch bump is not written. Camera input across batches must not lose an active pan solely because its own epoch changed; semantic releases must reject stale contexts.
|
||||
7. Scope widget focus identity currently omits revision. Review delayed Focus Tasks against replaced modal/document/revision state. Pending structured editors must own keyboard input; a retained background canvas focus must not leak arrows/Space into camera actions.
|
||||
8. Scenario caller frames already clone Presentation, so they can retain the new pan. Current ScrollRestore receipt machinery still targets the old Canvas Scrollable; migrate actual canvas restoration coherently while preserving Page/outline restoration, exact caller identity, one-shot behavior and raw guards. Process caller restoration/reveal currently targets Scrollable and also needs camera adaptation.
|
||||
9. Existing scenario FormShown/name focus, one-time marker reveal and staged child-source editor must survive the new stable editor presentation. Keep the same Draft and source_draft state; do not create a second draft, implicit Apply or substitute definitions.
|
||||
10. Existing workspace/scroll tests target old hosts. Replace obsolete presentation expectations with production-mounted canvas assertions while retaining all semantic/source/history/identity expectations. Do not leave legacy helper-only tests represented as new production acceptance.
|
||||
|
||||
## Next bounded work after authorization
|
||||
|
||||
Finish minimal BOTH-scene Fill/palette/editor adapters, guarded camera App events, stable caller return and whole-glyph scenario reorder. Exercise real Iced root layout and ordered pointer/key batches at nonzero pan and several zooms, frozen canvas bounds across edit/cancel, stale scope/release rejection, modal keyboard ownership, and exact App source/history/raw preservation. Send a coherent source freeze to root for compile and native gates; this worker remains prohibited from running Cargo/native/Git.
|
||||
|
||||
No persistent layout map, core/view schema extension, range wrapping, reconnection expansion, new allocation semantics or B4 changes belong in this slice. Root is compiling the original combined c0e66f9 source independently; do not modify that checkout or equate its results with this WIP. The incoming source_draft essentials-first fix is owned by Codex-9e8r6t; B4 renderer by Codex-ytr83r. Phase 1 is not issue #2 completion.
|
||||
|
||||
## Tool outcome
|
||||
|
||||
The first mechanical edit command attempted plain python and failed because python was not on PATH; it changed no files. The same intended edits were then applied successfully with the bundled Python at C:/Users/User/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe. Shared new files were written through apply_patch. No compile, formatter, unit test or native acceptance has run on these changes.
|
||||
Reference in New Issue
Block a user