Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555f2b9d6d | ||
|
|
563772dc62 | ||
|
|
17d56c06f7 | ||
|
|
36eff7225a | ||
|
|
b4c8fd7102 | ||
|
|
d8bdbd9e48 | ||
|
|
1e9f0c2a97 |
@@ -18,78 +18,6 @@
|
||||
harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-reopen-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-native-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-reboot-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-layout-reboot-20260911/run-gates2.ps1 whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/**/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
harness/experiments/wsl-capacity-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/wsl-postboot-9e8r6t-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-postboot-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-verified-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-full-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
harness/experiments/contextual-source-recovery-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-machine-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-lifeline-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-lifeline-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-machine-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-reopen-20260911/launch-method.ps1 whitespace=-blank-at-eof,cr-at-eol
|
||||
# Raw compact.exe output includes CR-CR-LF and incidental trailing spaces.
|
||||
harness/experiments/wsl-capacity-20260911/*.log -whitespace
|
||||
harness/experiments/contextual-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-prerelease-publication-20260911/** -text whitespace=cr-at-eol
|
||||
harness/history/2026-09-11-state-before-gitea-migration/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/native/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/producer/package*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/producer/source-push.log -whitespace
|
||||
harness/experiments/source-alias-rename-native-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-completed-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-refresh-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-native-closure-20260912/** -text whitespace=cr-at-eol
|
||||
# Preserve Text-workspace input, native and review evidence at its recorded bytes.
|
||||
harness/experiments/text-workspace-initial-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-initial-review-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-find-selection-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-native2-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-followup-review-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-native3-20260913/** -text whitespace=cr-at-eol
|
||||
# These checksum-bound baseline/expected fixtures deliberately end with blank lines.
|
||||
harness/experiments/text-workspace-native2-20260913/fixtures/**/*.arcadia whitespace=-blank-at-eof,cr-at-eol
|
||||
# Captured process streams retain intentional blank lines and diagnostic spacing.
|
||||
harness/experiments/text-find-selection-20260913/**/*.log -whitespace
|
||||
harness/experiments/text-workspace-initial-20260913/**/*.log -whitespace
|
||||
harness/experiments/text-workspace-native2-20260913/**/*.log -whitespace
|
||||
harness/experiments/text-workspace-native3-20260913/**/*.log -whitespace
|
||||
# Preserve the pinned dependency and diagnostic verification records verbatim.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-diagnostics-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-diagnostics-20260913/**/*.log -whitespace
|
||||
|
||||
@@ -11,4 +11,3 @@
|
||||
/model-store/
|
||||
/.local/
|
||||
.arcadia-save-*/
|
||||
__pycache__/
|
||||
|
||||
Generated
+2
-10
@@ -1567,6 +1567,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "iced_widget"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab"
|
||||
dependencies = [
|
||||
"iced_renderer",
|
||||
"log",
|
||||
@@ -2137,15 +2139,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-cli"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
@@ -2174,7 +2167,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-5
@@ -1,11 +1,7 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
|
||||
exclude = ["crates/vendor/iced_widget"]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
|
||||
resolver = "2"
|
||||
|
||||
[patch.crates-io]
|
||||
iced_widget = { path = "crates/vendor/iced_widget" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -18,14 +18,12 @@ From the repository root:
|
||||
cargo run --locked -p oa-desktop --bin arcadia-studio
|
||||
```
|
||||
|
||||
Build the desktop, server and source CLI release executables:
|
||||
Build both desktop and server release executables:
|
||||
|
||||
```sh
|
||||
cargo build --release --workspace --locked
|
||||
```
|
||||
|
||||
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
|
||||
|
||||
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
|
||||
|
||||
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
|
||||
@@ -51,34 +49,17 @@ The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `di
|
||||
|
||||
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
|
||||
|
||||
## Work with source files from the command line
|
||||
|
||||
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
|
||||
starting the desktop or server:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
|
||||
```
|
||||
|
||||
`stabilize ROOT` previews the identities needed for persistent editing;
|
||||
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
|
||||
`--write` to save that invocation's candidate, or `--json` for structured
|
||||
diagnostics and changed-file text/diffs. Preview leaves files untouched.
|
||||
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
|
||||
|
||||
## Model operational needs and system responsibilities
|
||||
|
||||
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
|
||||
|
||||
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
|
||||
|
||||
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
|
||||
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
|
||||
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
|
||||
|
||||
@@ -102,8 +83,6 @@ In the state view, **Select / move** drags vertices and containers without repar
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
|
||||
|
||||
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "oa-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "arcadia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
oa-core = { path = "../oa-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -1,451 +0,0 @@
|
||||
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
|
||||
use oa_core::{
|
||||
Command, language,
|
||||
persistence::MigrationReport,
|
||||
prepare_command,
|
||||
source_files::{self, DiskProject, SourceFileError},
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LIMITS: ProjectLimits = ProjectLimits {
|
||||
max_files: 256,
|
||||
max_include_depth: 32,
|
||||
max_include_edges: 1024,
|
||||
model: language::ParseLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_graph_objects: 100_000,
|
||||
max_records: 250_000,
|
||||
max_step_references: 500_000,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Operation {
|
||||
Check,
|
||||
Stabilize,
|
||||
RenameAlias { old: String, new: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Location {
|
||||
pub path: String,
|
||||
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
pub severity: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub coordinate_space: String,
|
||||
pub location: Option<Location>,
|
||||
pub related: Vec<Location>,
|
||||
pub object_id: Option<String>,
|
||||
pub field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
/// Exact captured and proposed UTF-8 text, including line endings.
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Report {
|
||||
pub format_version: u32,
|
||||
pub entry: String,
|
||||
pub operation: String,
|
||||
pub status: String,
|
||||
pub exit_code: u8,
|
||||
/// True only after a successful changed save. False on failure does not
|
||||
/// promise rollback: consult the specific error/recovery outcome.
|
||||
pub written: bool,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub changes: Vec<Change>,
|
||||
pub identities_to_stabilize: usize,
|
||||
pub migration: Option<MigrationReport>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn failure(
|
||||
entry: &Path,
|
||||
operation: &str,
|
||||
exit_code: u8,
|
||||
code: &str,
|
||||
message: String,
|
||||
) -> Self {
|
||||
let mut report = Self::empty(entry, operation);
|
||||
report.fail(exit_code, code, message);
|
||||
report
|
||||
}
|
||||
|
||||
fn empty(entry: &Path, operation: &str) -> Self {
|
||||
Self {
|
||||
format_version: 1,
|
||||
entry: entry.to_string_lossy().into_owned(),
|
||||
operation: operation.into(),
|
||||
status: "checked".into(),
|
||||
exit_code: 0,
|
||||
written: false,
|
||||
diagnostics: vec![],
|
||||
changes: vec![],
|
||||
identities_to_stabilize: 0,
|
||||
migration: None,
|
||||
notes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
|
||||
self.status = "rejected".into();
|
||||
self.exit_code = exit_code;
|
||||
self.diagnostics.push(Diagnostic {
|
||||
severity: "error".into(),
|
||||
code: code.into(),
|
||||
message,
|
||||
coordinate_space: "operation".into(),
|
||||
location: None,
|
||||
related: vec![],
|
||||
object_id: None,
|
||||
field: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
|
||||
let d = issue.diagnostic;
|
||||
Diagnostic {
|
||||
severity: match d.severity {
|
||||
Severity::Error => "error",
|
||||
Severity::Warning => "warning",
|
||||
}
|
||||
.into(),
|
||||
code: d.code,
|
||||
message: d.message,
|
||||
coordinate_space: space.into(),
|
||||
location: Some(Location {
|
||||
path: issue.path,
|
||||
start: d.span.start,
|
||||
end: d.span.end,
|
||||
line: Some(d.line),
|
||||
column: Some(d.column),
|
||||
}),
|
||||
related: issue
|
||||
.related
|
||||
.into_iter()
|
||||
.map(|at| Location {
|
||||
path: at.path,
|
||||
start: at.span.start,
|
||||
end: at.span.end,
|
||||
line: None,
|
||||
column: None,
|
||||
})
|
||||
.collect(),
|
||||
object_id: d.object_id.map(|id| id.to_string()),
|
||||
field: d.field,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_error(report: &mut Report, error: SourceFileError) {
|
||||
let code = match &error {
|
||||
SourceFileError::Conflict(_) => "external_change",
|
||||
SourceFileError::RecoveryRequired(_) => "recovery_required",
|
||||
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
|
||||
SourceFileError::Path(_) => "unsafe_path",
|
||||
SourceFileError::Limit(_) => "source_limit",
|
||||
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
|
||||
SourceFileError::Invalid(_) => "invalid_save_candidate",
|
||||
SourceFileError::Io { .. } => "io_error",
|
||||
};
|
||||
report.fail(3, code, error.to_string());
|
||||
}
|
||||
|
||||
/// A candidate owns its original expectations and is consumed by saving.
|
||||
/// Callers cannot replace its source/model/expectations after preparation.
|
||||
pub struct Prepared {
|
||||
report: Report,
|
||||
snapshot: DiskProject,
|
||||
compiled: CompiledSourceProject,
|
||||
mutable: bool,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn report(&self) -> &Report {
|
||||
&self.report
|
||||
}
|
||||
|
||||
pub fn into_report(self) -> Report {
|
||||
self.report
|
||||
}
|
||||
|
||||
pub fn write(mut self) -> Report {
|
||||
if !self.mutable {
|
||||
self.report
|
||||
.fail(2, "usage", "check/lint does not accept --write".into());
|
||||
return self.report;
|
||||
}
|
||||
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
|
||||
Ok(_) => {
|
||||
self.report.written = !self.report.changes.is_empty();
|
||||
self.report.status = if self.report.written {
|
||||
"written"
|
||||
} else {
|
||||
"unchanged"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Err(error) => disk_error(&mut self.report, error),
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
|
||||
let name = match operation {
|
||||
Operation::Check => "check",
|
||||
Operation::Stabilize => "stabilize",
|
||||
Operation::RenameAlias { .. } => "rename-alias",
|
||||
};
|
||||
let mut report = Report::empty(entry, name);
|
||||
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
|
||||
disk_error(&mut report, error);
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
let raw = snapshot.reachable_bundle();
|
||||
// Keep discovery/IO failures that lint cannot recover from the bundle alone.
|
||||
// Recompute source diagnostics below, against the same original bytes.
|
||||
let mut diagnostics: Vec<_> = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save"
|
||||
| "source_encoding"
|
||||
| "source_limit"
|
||||
| "invalid_path"
|
||||
| "missing_include"
|
||||
| "project_limit"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
|
||||
if !diagnostics.contains(&diagnostic) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save" | "missing_include" | "invalid_path"
|
||||
)
|
||||
});
|
||||
let invalid = diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == Severity::Error);
|
||||
report.diagnostics = diagnostics
|
||||
.into_iter()
|
||||
.map(|d| located(d, "input"))
|
||||
.collect();
|
||||
if invalid {
|
||||
// Do not run declaration analysis or any updater on invalid raw text.
|
||||
// Failed lint may also have allocated provisional object identities.
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = if disk_refusal { 3 } else { 1 };
|
||||
return Err(Box::new(report));
|
||||
}
|
||||
let explicit_ids: std::collections::BTreeSet<_> = raw
|
||||
.files
|
||||
.values()
|
||||
.flat_map(|text| language::analyze(text).declarations)
|
||||
.filter_map(|d| d.id.map(|id| id.to_string()))
|
||||
.collect();
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
if diagnostic
|
||||
.object_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !explicit_ids.contains(id))
|
||||
{
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
}
|
||||
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
report.identities_to_stabilize = raw
|
||||
.files
|
||||
.values()
|
||||
.map(|text| {
|
||||
language::analyze(text)
|
||||
.declarations
|
||||
.into_iter()
|
||||
.filter(|d| d.id.is_none())
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
report.migration = compiled.decoded.migration.clone();
|
||||
let mutable = !matches!(operation, Operation::Check);
|
||||
if let Operation::RenameAlias { old, new } = operation {
|
||||
let project = &compiled.decoded.project;
|
||||
let Some((id, _)) = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == old)
|
||||
else {
|
||||
report.fail(
|
||||
1,
|
||||
"unknown_alias",
|
||||
format!("No declaration has alias '{old}'."),
|
||||
);
|
||||
return Err(Box::new(report));
|
||||
};
|
||||
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
|
||||
report.fail(1, "semantic_command_rejected", error.to_string());
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
compiled = source_project::update_bundle_with_identity_map(
|
||||
&compiled,
|
||||
&command.project,
|
||||
&command.identity_map,
|
||||
LIMITS,
|
||||
)
|
||||
.map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
}
|
||||
if mutable {
|
||||
for (path, after) in &compiled.bundle.files {
|
||||
let before = raw.files.get(path).cloned().unwrap_or_default();
|
||||
if &before != after {
|
||||
report.changes.push(Change {
|
||||
path: path.clone(),
|
||||
diff: unified_diff(path, &before, after),
|
||||
before,
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
report.status = "preview".into();
|
||||
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
|
||||
}
|
||||
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
|
||||
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
|
||||
Ok(Prepared {
|
||||
report,
|
||||
snapshot,
|
||||
compiled,
|
||||
mutable,
|
||||
})
|
||||
}
|
||||
|
||||
/// One bounded hunk per file: common prefix/suffix plus three context lines.
|
||||
/// Linear in input size; no quadratic LCS allocation for large modules.
|
||||
fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
||||
let a: Vec<_> = before.split_inclusive('\n').collect();
|
||||
let b: Vec<_> = after.split_inclusive('\n').collect();
|
||||
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
||||
let suffix = a[prefix..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(b[prefix..].iter().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let start = prefix.saturating_sub(3);
|
||||
let a_end = (a.len() - suffix + 3).min(a.len());
|
||||
let b_end = (b.len() - suffix + 3).min(b.len());
|
||||
let a_start = if a_end == start { start } else { start + 1 };
|
||||
let b_start = if b_end == start { start } else { start + 1 };
|
||||
let mut out = format!(
|
||||
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
|
||||
a_end - start,
|
||||
b_end - start
|
||||
);
|
||||
let mut emit = |prefix: char, line: &str| {
|
||||
out.push(prefix);
|
||||
out.push_str(line);
|
||||
if !line.ends_with('\n') {
|
||||
out.push_str("\n\\ No newline at end of file\n");
|
||||
}
|
||||
};
|
||||
for line in &a[start..prefix] {
|
||||
emit(' ', line);
|
||||
}
|
||||
for line in &a[prefix..a.len() - suffix] {
|
||||
emit('-', line);
|
||||
}
|
||||
for line in &b[prefix..b.len() - suffix] {
|
||||
emit('+', line);
|
||||
}
|
||||
for line in &a[a.len() - suffix..a_end] {
|
||||
emit(' ', line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Arguments {
|
||||
pub entry: PathBuf,
|
||||
pub operation: Operation,
|
||||
pub write: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
|
||||
|
||||
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
||||
let mut positional = Vec::new();
|
||||
let (mut write, mut json) = (false, false);
|
||||
for arg in args {
|
||||
if arg == "--write" && !write {
|
||||
write = true;
|
||||
} else if arg == "--json" && !json {
|
||||
json = true;
|
||||
} else if arg.to_string_lossy().starts_with('-') {
|
||||
return Err(format!(
|
||||
"Unknown or repeated option: {}",
|
||||
arg.to_string_lossy()
|
||||
));
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let operation = match (command, positional.len()) {
|
||||
("check" | "lint", 2) if !write => Operation::Check,
|
||||
("stabilize", 2) => Operation::Stabilize,
|
||||
("rename-alias", 4) => Operation::RenameAlias {
|
||||
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
},
|
||||
_ => return Err(HELP.into()),
|
||||
};
|
||||
Ok(Arguments {
|
||||
entry: PathBuf::from(positional[1]),
|
||||
operation,
|
||||
write,
|
||||
json,
|
||||
})
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
use oa_cli::{Report, arguments, prepare};
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
|
||||
let mut err = io::stderr().lock();
|
||||
for d in &report.diagnostics {
|
||||
let at = d
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|l| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
l.path,
|
||||
l.line.unwrap_or(0),
|
||||
l.column.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| report.entry.clone());
|
||||
writeln!(
|
||||
err,
|
||||
"{at}: {}[{}] ({}): {}",
|
||||
d.severity, d.code, d.coordinate_space, d.message
|
||||
)?;
|
||||
}
|
||||
writeln!(
|
||||
err,
|
||||
"{}: {} file changes; {} identities to stabilize",
|
||||
report.status,
|
||||
report.changes.len(),
|
||||
report.identities_to_stabilize
|
||||
)?;
|
||||
if let Some(migration) = &report.migration {
|
||||
writeln!(
|
||||
err,
|
||||
"Source migration: schema {} → {} (separate from identity stabilization)",
|
||||
migration.from_schema, migration.to_schema
|
||||
)?;
|
||||
}
|
||||
for note in &report.notes {
|
||||
writeln!(err, "{note}")?;
|
||||
}
|
||||
if show_diff {
|
||||
let mut out = io::stdout().lock();
|
||||
for change in &report.changes {
|
||||
write!(out, "{}", change.diff)?;
|
||||
}
|
||||
out.flush()?;
|
||||
}
|
||||
err.flush()
|
||||
}
|
||||
|
||||
fn run() -> io::Result<u8> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
|
||||
return Ok(0);
|
||||
}
|
||||
if args.len() == 1 && args[0] == "--version" {
|
||||
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
|
||||
return Ok(0);
|
||||
}
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let mut displayed = false;
|
||||
let report = match arguments(&args) {
|
||||
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
|
||||
Ok(args) => match prepare(&args.entry, args.operation) {
|
||||
Err(report) => *report,
|
||||
Ok(prepared) => {
|
||||
if args.write {
|
||||
if !args.json {
|
||||
human_report(prepared.report(), true)?;
|
||||
displayed = true;
|
||||
}
|
||||
prepared.write()
|
||||
} else {
|
||||
prepared.into_report()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if json {
|
||||
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
|
||||
writeln!(io::stdout())?;
|
||||
} else {
|
||||
human_report(&report, !displayed)?;
|
||||
}
|
||||
Ok(report.exit_code)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
let _ = writeln!(io::stderr(), "Output failed: {error}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
use oa_cli::{LIMITS, Operation, Prepared, prepare};
|
||||
use oa_core::{Id, Project, source_files, source_project};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn entry(&self) -> PathBuf {
|
||||
self.0.join("project.arcadia")
|
||||
}
|
||||
fn read(&self, name: &str) -> String {
|
||||
fs::read_to_string(self.0.join(name)).unwrap()
|
||||
}
|
||||
fn prepare(&self, op: Operation) -> Prepared {
|
||||
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
|
||||
}
|
||||
fn model(&self) -> Project {
|
||||
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
|
||||
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project
|
||||
}
|
||||
fn cli(&self, args: &[&str]) -> (i32, Value) {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
|
||||
command
|
||||
.arg(args[0])
|
||||
.arg(self.entry())
|
||||
.args(&args[1..])
|
||||
.arg("--json");
|
||||
let output = command.output().unwrap();
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
(
|
||||
output.status.code().unwrap(),
|
||||
serde_json::from_slice(&output.stdout).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
|
||||
|
||||
#[test]
|
||||
fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
let fixture = Fixture::new();
|
||||
let source = include_str!(
|
||||
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
||||
);
|
||||
fixture.put("project.arcadia", source);
|
||||
let (code, check) = fixture.cli(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(check["diagnostics"], serde_json::json!([]));
|
||||
let (code, preview) = fixture.cli(&["stabilize"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(preview["identities_to_stabilize"], 40);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert!(
|
||||
preview["changes"][0]["diff"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("+project alarm_response")
|
||||
);
|
||||
let (code, written) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let saved = fixture.read("project.arcadia");
|
||||
assert_eq!(written["changes"][0]["after"], saved);
|
||||
let project = fixture.model();
|
||||
assert_eq!(project.identities().len(), 40);
|
||||
let mut stripped = saved.clone();
|
||||
for (id, _) in project.identities() {
|
||||
stripped = stripped.replace(&format!(" id \"{id}\""), "");
|
||||
}
|
||||
assert_eq!(stripped, source, "only identity clauses may be inserted");
|
||||
let (code, again) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(again["changes"], serde_json::json!([]));
|
||||
assert_eq!(again["status"], "unchanged");
|
||||
assert_eq!(fixture.model(), project);
|
||||
assert_eq!(fixture.read("project.arcadia"), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let candidate = fixture.prepare(Operation::Stabilize);
|
||||
let expected = candidate.report().changes[0].after.clone();
|
||||
assert_eq!(candidate.report().identities_to_stabilize, 2);
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
assert_eq!(fixture.read("project.arcadia"), expected);
|
||||
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
|
||||
assert!(!expected.contains("description"));
|
||||
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
|
||||
let report = fixture.prepare(Operation::Stabilize).write();
|
||||
assert_eq!(report.status, "unchanged");
|
||||
assert!(!report.written);
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
|
||||
fixture.put(
|
||||
"participants.arcadia",
|
||||
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
|
||||
);
|
||||
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
|
||||
.into_iter()
|
||||
.map(|p| (p, fixture.read(p)))
|
||||
.collect();
|
||||
let team = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "team")
|
||||
.unwrap()
|
||||
.0;
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "rescue_team".into(),
|
||||
});
|
||||
assert_eq!(candidate.report().changes.len(), 2);
|
||||
assert_eq!(fixture.model(), before, "preview cannot mutate");
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
let mut expected = before;
|
||||
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
|
||||
assert_eq!(
|
||||
fixture.read("participants.arcadia"),
|
||||
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("work.arcadia"),
|
||||
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
for command in [
|
||||
vec!["check"],
|
||||
vec!["stabilize", "--write"],
|
||||
vec!["rename-alias", "a", "b", "--write"],
|
||||
] {
|
||||
let (code, report) = fixture.cli(&command);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
let diagnostics = report["diagnostics"].as_array().unwrap();
|
||||
assert!(!diagnostics.is_empty());
|
||||
for d in diagnostics {
|
||||
assert_eq!(d["coordinate_space"], "input");
|
||||
let at = &d["location"];
|
||||
let start = at["start"].as_u64().unwrap() as usize;
|
||||
assert!(raw.is_char_boundary(start));
|
||||
assert_eq!(
|
||||
at["line"].as_u64().unwrap() as usize,
|
||||
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
|
||||
);
|
||||
let line = raw[..start]
|
||||
.rsplit_once('\n')
|
||||
.map_or(&raw[..start], |(_, tail)| tail);
|
||||
assert_eq!(
|
||||
at["column"].as_u64().unwrap() as usize,
|
||||
line.chars().count() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, result) = fixture.cli(&["lint"]);
|
||||
assert_eq!(code, 0, "engineering warnings do not reject admission");
|
||||
let warning = result["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["code"] == "work_without_behavior_context")
|
||||
.unwrap();
|
||||
assert_eq!(warning["severity"], "warning");
|
||||
assert_eq!(
|
||||
warning["location"]["start"],
|
||||
raw.find("oa_activity").unwrap()
|
||||
);
|
||||
assert_eq!(warning["location"]["line"], 3);
|
||||
assert_eq!(warning["location"]["column"], 5);
|
||||
assert!(warning["object_id"].is_null());
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let pending = fixture.prepare(Operation::Stabilize);
|
||||
let external = format!("{SIMPLE}// editor changed this\n");
|
||||
fixture.put("project.arcadia", &external);
|
||||
let result = pending.write();
|
||||
assert_eq!(result.exit_code, 3);
|
||||
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
|
||||
assert_eq!(fixture.read("project.arcadia"), external);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
assert!(noop.report().changes.is_empty());
|
||||
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
|
||||
fixture.put("project.arcadia", &latest);
|
||||
assert_eq!(noop.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
let source = fixture.read("project.arcadia");
|
||||
// An opaque incomplete journal must never be interpreted/recovered by CLI.
|
||||
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("sentinel"), b"retain").unwrap();
|
||||
for op in [Operation::Check, Operation::Stabilize] {
|
||||
let report = prepare(&fixture.entry(), op).err().unwrap();
|
||||
assert_eq!(report.exit_code, 3);
|
||||
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
|
||||
}
|
||||
assert_eq!(
|
||||
noop.write().exit_code,
|
||||
3,
|
||||
"saver must recheck after preview"
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_alias_and_same_alias_preserve_source() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let source = fixture.read("project.arcadia");
|
||||
for (old, new) in [
|
||||
("team", "rescue"),
|
||||
("team", "invalid alias"),
|
||||
("absent", "valid"),
|
||||
] {
|
||||
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(result["status"], "rejected");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(result["status"], "unchanged");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_missing_identity_and_separate_source_migration() {
|
||||
let fixture = Fixture::new();
|
||||
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
|
||||
fixture.put("project.arcadia", old);
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "crew".into(),
|
||||
});
|
||||
let report = candidate.report();
|
||||
assert_eq!(report.identities_to_stabilize, 2);
|
||||
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
|
||||
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
|
||||
assert!(report.changes[0].diff.contains("-arcadia 5;"));
|
||||
assert!(report.changes[0].diff.contains("+arcadia 7;"));
|
||||
assert!(report.changes[0].after.contains("oa_entity crew"));
|
||||
assert_eq!(fixture.read("project.arcadia"), old);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
|
||||
.current_dir(&fixture.0)
|
||||
.args(["check", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(error["diagnostics"][0]["code"], "usage");
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
let (code, _) = fixture.cli(&["check", "--write"]);
|
||||
assert_eq!(code, 2);
|
||||
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
|
||||
let fixture = Fixture::new();
|
||||
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
|
||||
fixture.put("project.arcadia", entry);
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 3, "a disk dependency could not be read");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "missing_include")
|
||||
);
|
||||
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1, "invalid encoding is invalid raw source");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "source_encoding")
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), entry);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("part.arcadia")).unwrap(),
|
||||
[0xff, 0xfe]
|
||||
);
|
||||
for raw in [
|
||||
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
|
||||
"arcadia 2; project p \"P\" {}",
|
||||
] {
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, _) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
|
||||
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
|
||||
fixture.put(
|
||||
"detached.arcadia",
|
||||
"invalid detached draft must stay exact 雨",
|
||||
);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let relation = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "responsible")
|
||||
.unwrap()
|
||||
.0;
|
||||
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let mut expected = before;
|
||||
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert!(
|
||||
fixture
|
||||
.read("project.arcadia")
|
||||
.contains("@ // keep edge comment\nteam")
|
||||
);
|
||||
let captured = fixture.prepare(Operation::RenameAlias {
|
||||
old: "p".into(),
|
||||
new: "rescue".into(),
|
||||
});
|
||||
assert!(
|
||||
captured
|
||||
.report()
|
||||
.changes
|
||||
.iter()
|
||||
.all(|c| c.path != "part.arcadia")
|
||||
);
|
||||
let root_before = fixture.read("project.arcadia");
|
||||
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
|
||||
assert_eq!(captured.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), root_before);
|
||||
assert_eq!(
|
||||
fixture.read("part.arcadia"),
|
||||
"// external unchanged-dependency edit\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("detached.arcadia"),
|
||||
"invalid detached draft must stay exact 雨"
|
||||
);
|
||||
}
|
||||
+20
-153
@@ -142,87 +142,12 @@ pub struct Analysis {
|
||||
pub declarations: Vec<DeclarationSpan>,
|
||||
}
|
||||
|
||||
/// Check the decoded spelling required by declaration-header UUID clauses.
|
||||
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
||||
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
||||
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
||||
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
||||
decoded.len() == 36
|
||||
&& [8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| decoded.as_bytes()[index] == b'-')
|
||||
}
|
||||
|
||||
/// Budgets for one original source buffer. Policy is caller-owned; these
|
||||
/// limits do not change the grammar or perform model admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimits {
|
||||
pub max_source_bytes: usize,
|
||||
pub max_tokens: usize,
|
||||
pub max_declarations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutlineResource {
|
||||
SourceBytes,
|
||||
Tokens,
|
||||
Declarations,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimit {
|
||||
pub resource: OutlineResource,
|
||||
pub limit: usize,
|
||||
pub observed_at_least: usize,
|
||||
/// Original byte offset where admission stopped; never a rewritten offset.
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
||||
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineDeclaration {
|
||||
pub declaration: DeclarationSpan,
|
||||
pub kind_span: Span,
|
||||
pub alias_span: Span,
|
||||
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
||||
/// case declaration.id is None. Missing/non-string ID values have no span.
|
||||
pub id_literal_span: Option<Span>,
|
||||
}
|
||||
|
||||
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
||||
/// text remains available. Lexical errors and unmatched braces are facts only:
|
||||
/// their absence does not establish syntactic or semantic validity.
|
||||
///
|
||||
/// A budget failure returns Err and no partial Outline. Include reachability,
|
||||
/// project completeness and editor revision are intentionally not inferred here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outline<'a> {
|
||||
pub source: &'a str,
|
||||
pub tokens: Vec<TokenSpan>,
|
||||
pub declarations: Vec<OutlineDeclaration>,
|
||||
pub has_lexical_errors: bool,
|
||||
pub has_unmatched_braces: bool,
|
||||
}
|
||||
|
||||
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
||||
/// strings and unexpected characters. No model is created or modified.
|
||||
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
||||
}
|
||||
|
||||
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
if tokens.len() == max_tokens {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Tokens,
|
||||
limit: max_tokens,
|
||||
observed_at_least: tokens.len().saturating_add(1),
|
||||
offset: cursor,
|
||||
});
|
||||
}
|
||||
let start = cursor;
|
||||
let ch = source[cursor..].chars().next().expect("in bounds");
|
||||
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
||||
@@ -299,7 +224,7 @@ fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
Ok(tokens)
|
||||
tokens
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -1305,7 +1230,11 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if !has_uuid_header_shape(&value) {
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -2033,49 +1962,8 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
|
||||
|
||||
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
||||
/// spans remain available even when a later declaration contains errors.
|
||||
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
||||
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
||||
pub fn analyze(source: &str) -> Analysis {
|
||||
let tokens = tokenize(source);
|
||||
let (declarations, _) =
|
||||
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
||||
/// Source bytes are admitted before token allocation; token/declaration limits
|
||||
/// are checked before lexing the next token or materializing the next record.
|
||||
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
||||
if source.len() > limits.max_source_bytes {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::SourceBytes,
|
||||
limit: limits.max_source_bytes,
|
||||
observed_at_least: source.len(),
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
||||
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
||||
let (declarations, has_unmatched_braces) =
|
||||
declaration_outline(source, &tokens, limits.max_declarations)?;
|
||||
Ok(Outline {
|
||||
source,
|
||||
tokens,
|
||||
declarations,
|
||||
has_lexical_errors,
|
||||
has_unmatched_braces,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration_outline(
|
||||
source: &str,
|
||||
tokens: &[TokenSpan],
|
||||
max_declarations: usize,
|
||||
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
||||
let significant = tokens
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
@@ -2084,21 +1972,17 @@ fn declaration_outline(
|
||||
// again would make tolerant navigation quadratic on deeply nested drafts.
|
||||
let mut body_ends = vec![None; significant.len()];
|
||||
let mut bodies = Vec::new();
|
||||
let mut unmatched_close = false;
|
||||
for (index, token) in significant.iter().enumerate() {
|
||||
match &source[token.span.start..token.span.end] {
|
||||
"{" => bodies.push(index),
|
||||
"}" => {
|
||||
if let Some(open) = bodies.pop() {
|
||||
body_ends[open] = Some(token.span.end);
|
||||
} else {
|
||||
unmatched_close = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
||||
let mut declarations = Vec::new();
|
||||
for (i, t) in significant.iter().enumerate() {
|
||||
let word = &source[t.span.start..t.span.end];
|
||||
@@ -2123,16 +2007,7 @@ fn declaration_outline(
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if declarations.len() == max_declarations {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Declarations,
|
||||
limit: max_declarations,
|
||||
observed_at_least: declarations.len().saturating_add(1),
|
||||
offset: t.span.start,
|
||||
});
|
||||
}
|
||||
let mut id = None;
|
||||
let mut id_literal_span = None;
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
||||
@@ -2140,31 +2015,23 @@ fn declaration_outline(
|
||||
if let Some(value) = significant.get(j + 1)
|
||||
&& value.class == TokenClass::String
|
||||
{
|
||||
id_literal_span = Some(value.span);
|
||||
let text =
|
||||
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
||||
.unwrap();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
// A recognized header ID at EOF must remain inside the whole span.
|
||||
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
||||
let header_end = significant
|
||||
.get(j.saturating_sub(1))
|
||||
.or_else(|| significant.last())
|
||||
.map_or(alias_token.span.end, |token| token.span.end);
|
||||
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
||||
{
|
||||
end = body_ends[j].unwrap_or(source.len());
|
||||
}
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: DeclarationSpan {
|
||||
declarations.push(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
@@ -2172,18 +2039,14 @@ fn declaration_outline(
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
kind_span: t.span,
|
||||
alias_span: alias_token.span,
|
||||
id_literal_span,
|
||||
});
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
@@ -2579,7 +2442,11 @@ pub(crate) mod legacy {
|
||||
let value = self.quoted()?;
|
||||
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
||||
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
//! Prepared outline tests. Explicit compatibility tests additionally exercise
|
||||
//! the existing parsers/analyze; the outline entry itself never calls them.
|
||||
use super::*;
|
||||
|
||||
fn limits() -> OutlineLimits {
|
||||
OutlineLimits {
|
||||
max_source_bytes: 1024 * 1024,
|
||||
max_tokens: 100_000,
|
||||
max_declarations: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
|
||||
result
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.declaration.alias == alias)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
|
||||
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
|
||||
let before = source.as_bytes().to_vec();
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source.as_bytes(), before);
|
||||
assert_eq!(result.source.as_ptr(), source.as_ptr());
|
||||
let d = declaration(&result, "responder");
|
||||
assert_eq!(text(source, d.kind_span), "oa_actor");
|
||||
assert_eq!(text(source, d.alias_span), "responder");
|
||||
assert_eq!(
|
||||
text(source, d.id_literal_span.unwrap()),
|
||||
"\"\\u00300000000-0000-0000-0000-000000000001\""
|
||||
);
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
|
||||
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
|
||||
let mut end = 0;
|
||||
for token in &result.tokens {
|
||||
assert_eq!(token.span.start, end);
|
||||
assert!(token.span.start < token.span.end);
|
||||
assert!(source.is_char_boundary(token.span.start));
|
||||
assert!(source.is_char_boundary(token.span.end));
|
||||
end = token.span.end;
|
||||
}
|
||||
assert_eq!(end, source.len());
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_id_at_eof_is_inside_the_declaration_extent() {
|
||||
for source in [
|
||||
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
|
||||
"node placed id \"00000000-0000-0000-0000-000000000001\"",
|
||||
] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
let literal = d.id_literal_span.unwrap();
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert!(d.declaration.span.start <= literal.start);
|
||||
assert!(literal.end <= d.declaration.span.end);
|
||||
}
|
||||
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(d.id_literal_span, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
|
||||
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
|
||||
let first = outline(source, limits()).unwrap();
|
||||
let second = outline(source, limits()).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
first
|
||||
.declarations
|
||||
.iter()
|
||||
.all(|d| d.declaration.id.is_none())
|
||||
);
|
||||
assert_eq!(declaration(&first, "missing").id_literal_span, None);
|
||||
assert_eq!(declaration(&first, "historical").id_literal_span, None);
|
||||
assert_eq!(
|
||||
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
|
||||
"\"not-a-uuid\""
|
||||
);
|
||||
// Balanced tokens are not a successful syntax/semantic validation claim.
|
||||
assert!(!first.has_lexical_errors);
|
||||
assert!(!first.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
|
||||
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source, source);
|
||||
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
|
||||
assert!(result.has_lexical_errors);
|
||||
assert!(result.has_unmatched_braces);
|
||||
let ready = declaration(&result, "ready");
|
||||
assert_eq!(
|
||||
text(source, ready.declaration.span),
|
||||
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "outer").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "pending").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
|
||||
assert!(unmatched_close.has_unmatched_braces);
|
||||
assert!(!unmatched_close.has_lexical_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
|
||||
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 1);
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
// There is deliberately no `valid` flag: the known-complete fact is scanning
|
||||
// these bytes, not the missing body becoming legal or the draft being applied.
|
||||
assert_eq!(result.source, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
|
||||
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 5);
|
||||
let duplicates: Vec<_> = result
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|d| d.declaration.alias == "oa_activity")
|
||||
.collect();
|
||||
assert_eq!(duplicates.len(), 2);
|
||||
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
|
||||
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
|
||||
assert!(
|
||||
result
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
|
||||
);
|
||||
assert_eq!(
|
||||
text(source, declaration(&result, "placement").kind_span),
|
||||
"node"
|
||||
);
|
||||
assert_eq!(declaration(&result, "placement").declaration.id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_refuse_before_returning_a_partial_outline() {
|
||||
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
|
||||
let admitted = outline(source, limits()).unwrap();
|
||||
let exact = OutlineLimits {
|
||||
max_source_bytes: source.len(),
|
||||
max_tokens: admitted.tokens.len(),
|
||||
max_declarations: 2,
|
||||
};
|
||||
assert_eq!(outline(source, exact).unwrap(), admitted);
|
||||
for resource in [
|
||||
OutlineResource::SourceBytes,
|
||||
OutlineResource::Tokens,
|
||||
OutlineResource::Declarations,
|
||||
] {
|
||||
let mut lower = exact;
|
||||
match resource {
|
||||
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
|
||||
OutlineResource::Tokens => lower.max_tokens -= 1,
|
||||
OutlineResource::Declarations => lower.max_declarations -= 1,
|
||||
}
|
||||
let error = outline(source, lower).unwrap_err();
|
||||
assert_eq!(error.resource, resource);
|
||||
assert!(error.observed_at_least > error.limit);
|
||||
assert!(source.is_char_boundary(error.offset));
|
||||
if resource == OutlineResource::Declarations {
|
||||
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
|
||||
}
|
||||
}
|
||||
let empty = outline(
|
||||
"",
|
||||
OutlineLimits {
|
||||
max_source_bytes: 0,
|
||||
max_tokens: 0,
|
||||
max_declarations: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
|
||||
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_source_bytes: 4,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::SourceBytes);
|
||||
assert_eq!(error.offset, 0);
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_tokens: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Tokens);
|
||||
assert_eq!(error.offset, unicode.find('\n').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
|
||||
let old = "oa_actor a \"A\" {}\nsource @a;";
|
||||
let current = format!("// 航海 🚒\r\n{old}");
|
||||
let old_outline = outline(old, limits()).unwrap();
|
||||
let current_outline = outline(¤t, limits()).unwrap();
|
||||
let shift = current.len() - old.len();
|
||||
let old_role = &old_outline.declarations[0];
|
||||
let current_role = ¤t_outline.declarations[0];
|
||||
assert_eq!(
|
||||
current_role.alias_span.start,
|
||||
old_role.alias_span.start + shift
|
||||
);
|
||||
assert_eq!(
|
||||
current_role.kind_span.start,
|
||||
old_role.kind_span.start + shift
|
||||
);
|
||||
assert_eq!(
|
||||
current_role.declaration.span.end,
|
||||
old_role.declaration.span.end + shift
|
||||
);
|
||||
assert_eq!(current_outline.source, current);
|
||||
assert_eq!(old_outline.source, old);
|
||||
assert_eq!(current_role.declaration.id, None);
|
||||
assert!(!current_outline.source.contains(" id "));
|
||||
// Workspace revision/stale-handle rejection belongs to the query/adapter,
|
||||
// not a claim that an old owned span can be applied to any other string.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
|
||||
let mut source = String::new();
|
||||
for index in 0..600 {
|
||||
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
|
||||
}
|
||||
source.push_str(&"}\n".repeat(600));
|
||||
let result = outline(&source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 600);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
assert_eq!(
|
||||
result.declarations[0].declaration.span.end,
|
||||
source.len() - 1
|
||||
);
|
||||
let error = outline(
|
||||
&source,
|
||||
OutlineLimits {
|
||||
max_declarations: 599,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Declarations);
|
||||
assert_eq!(error.observed_at_least, 600);
|
||||
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
|
||||
for raw in [
|
||||
"00000000000000000000000000000001",
|
||||
"{00000000-0000-0000-0000-000000000001}",
|
||||
"urn:uuid:00000000-0000-0000-0000-000000000001",
|
||||
] {
|
||||
assert!(
|
||||
Id::parse_str(raw).is_ok(),
|
||||
"The UUID crate alone is too permissive"
|
||||
);
|
||||
assert!(!has_uuid_header_shape(raw));
|
||||
let literal = format!("\"{raw}\"");
|
||||
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let before = modern.clone();
|
||||
let result = outline(&modern, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
|
||||
assert_eq!(modern, before);
|
||||
let errors = parse(&modern).unwrap_err();
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12")
|
||||
&& error.span.start == modern.find(&literal).unwrap())
|
||||
);
|
||||
// Intentional compatibility correction: old analyze yielded Some(Id(1))
|
||||
// here despite lint rejecting the header. It now shares outline's None.
|
||||
let analysis = analyze(&modern);
|
||||
assert_eq!(
|
||||
analysis
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.alias == "model")
|
||||
.unwrap()
|
||||
.id,
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12"))
|
||||
);
|
||||
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
|
||||
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
|
||||
assert!(
|
||||
legacy_errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
|
||||
let expected = Id::from_u128(0xabcdef);
|
||||
let canonical = expected.to_string();
|
||||
let uppercase = canonical.to_uppercase();
|
||||
assert_ne!(uppercase, canonical);
|
||||
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
|
||||
let literal = format!("\"{raw}\"");
|
||||
let decoded: String = serde_json::from_str(&literal).unwrap();
|
||||
assert!(has_uuid_header_shape(&decoded));
|
||||
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let result = outline(&modern, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, Some(expected));
|
||||
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
|
||||
assert_eq!(result.source.as_bytes(), modern.as_bytes());
|
||||
assert_eq!(parse(&modern).unwrap().id, expected);
|
||||
let analysis = analyze(&modern);
|
||||
assert_eq!(analysis.declarations[0].id, Some(expected));
|
||||
assert!(
|
||||
!analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|error| error.severity == crate::validation::Severity::Error)
|
||||
);
|
||||
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
|
||||
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
|
||||
let raw = "00000000-0000-0000-0000-00000000000Z";
|
||||
assert!(has_uuid_header_shape(raw));
|
||||
assert!(Id::parse_str(raw).is_err());
|
||||
let literal = format!("\"{raw}\"");
|
||||
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let result = outline(&source, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
|
||||
assert!(
|
||||
parse(&source)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|error| error.message == "Invalid UUID identity.")
|
||||
);
|
||||
assert_eq!(analyze(&source).declarations[0].id, None);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ pub enum DiskExpectation {
|
||||
|
||||
/// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`;
|
||||
/// use `diagnostics` to explain files that discovery could not read or parse.
|
||||
/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization.
|
||||
/// Expectations always describe disk bytes before UUID stabilization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskProject {
|
||||
@@ -527,11 +526,10 @@ pub fn discover_missing(
|
||||
result.expectations.keys().chain(result.bundle.files.keys()),
|
||||
)?;
|
||||
if result.diagnostics.is_empty() {
|
||||
// Discovery retains raw editor bytes. Compilation may insert UUIDs or
|
||||
// rewrite older syntax, so its returned locations belong to a different
|
||||
// bundle. Lint still performs semantic migration and model validation;
|
||||
// Open/Apply separately compile and install the rewritten source.
|
||||
result.diagnostics = lint_bundle(&result.reachable_bundle(), limits);
|
||||
match compile_bundle(&result.reachable_bundle(), limits) {
|
||||
Ok(compiled) => result.diagnostics = compiled.diagnostics,
|
||||
Err(diagnostics) => result.diagnostics = diagnostics,
|
||||
}
|
||||
}
|
||||
let pending = journal_path(&result.root, &result.bundle.entry);
|
||||
if fs::symlink_metadata(&pending).is_ok() {
|
||||
|
||||
@@ -383,87 +383,3 @@ fn atomic_save_preserves_existing_unix_permissions() {
|
||||
0o640
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_diagnostics_keep_raw_no_uuid_spans() {
|
||||
let directory = setup();
|
||||
let path = directory.path().join("parts/a.arcadia");
|
||||
let raw =
|
||||
"// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
|
||||
fs::write(&path, raw).unwrap();
|
||||
let disk = load_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disk.bundle.files["parts/a.arcadia"], raw);
|
||||
let issue = disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.diagnostic.code == "unallocated_leaf"
|
||||
&& issue.diagnostic.message.contains("Second")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(issue.path, "parts/a.arcadia");
|
||||
assert_eq!(issue.diagnostic.line, 3);
|
||||
assert_eq!(issue.diagnostic.column, 1);
|
||||
let span = issue.diagnostic.span;
|
||||
assert_eq!(span.start, raw.find("sa_function second").unwrap());
|
||||
assert_eq!(
|
||||
&raw[span.start..span.end],
|
||||
"sa_function second \"Second\" {}"
|
||||
);
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_ne!(compiled.bundle, disk.bundle);
|
||||
assert!(compiled.diagnostics.iter().any(|later| {
|
||||
later.diagnostic.code == issue.diagnostic.code
|
||||
&& later.diagnostic.message == issue.diagnostic.message
|
||||
&& later.diagnostic.span.start > span.start
|
||||
}));
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_diagnoses_legacy_semantics_before_source_rewrite() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.arcadia");
|
||||
let raw = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
fs::write(&path, raw).unwrap();
|
||||
let disk = load_project(&path, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(disk.bundle.files["project.arcadia"], raw);
|
||||
assert!(!disk.diagnostics.is_empty());
|
||||
assert!(
|
||||
disk.diagnostics
|
||||
.iter()
|
||||
.all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning })
|
||||
);
|
||||
assert_eq!(
|
||||
disk.diagnostics,
|
||||
lint_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
);
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert_ne!(compiled.bundle, disk.bundle);
|
||||
for issue in &disk.diagnostics {
|
||||
assert!(compiled.diagnostics.iter().any(|later| {
|
||||
later.diagnostic.code == issue.diagnostic.code
|
||||
&& later.diagnostic.object_id == issue.diagnostic.object_id
|
||||
&& later.diagnostic.message == issue.diagnostic.message
|
||||
}));
|
||||
let span = issue.diagnostic.span;
|
||||
assert!(raw.get(span.start..span.end).is_some());
|
||||
}
|
||||
// Invalid legacy references still fail during semantic resolution on the
|
||||
// authored bytes; deferring the source rewrite does not hide those errors.
|
||||
let invalid = raw.replace("@coordinate", "@missing");
|
||||
fs::write(&path, &invalid).unwrap();
|
||||
let broken = load_project(&path, ProjectLimits::default()).unwrap();
|
||||
assert!(broken.diagnostics.iter().any(|issue| {
|
||||
issue.diagnostic.severity == oa_core::validation::Severity::Error
|
||||
&& issue.diagnostic.message.contains("@missing")
|
||||
}));
|
||||
assert_eq!(broken.bundle.files["project.arcadia"], invalid);
|
||||
assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err());
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), invalid);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tempfile = "3"
|
||||
unicode-segmentation = "1.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
|
||||
+80
-774
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,6 @@ use super::*;
|
||||
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
|
||||
use serde::{Deserialize, Serialize};
|
||||
mod palette;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub use palette::view as palette;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -23,35 +21,9 @@ pub enum Event {
|
||||
Filter(Filter),
|
||||
ShowPlaced(bool),
|
||||
Page(usize),
|
||||
CapturedEscape,
|
||||
Edit,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub fn subscription(app: &App) -> Subscription<Message> {
|
||||
if app.architecture.as_ref().is_some_and(State::inline) {
|
||||
iced::event::listen_with(captured_escape)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_escape(
|
||||
event: iced::Event,
|
||||
status: iced::event::Status,
|
||||
_: iced::window::Id,
|
||||
) -> Option<Message> {
|
||||
// A focused Iced text input captures Escape while dropping its focus. Only
|
||||
// inline architecture naming opts into also cancelling that same keypress.
|
||||
(status == iced::event::Status::Captured
|
||||
&& matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(Message::Architecture(Event::CapturedEscape))
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Filter {
|
||||
@@ -364,15 +336,9 @@ impl App {
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::CapturedEscape => {
|
||||
if self.architecture.as_ref().is_some_and(State::inline) {
|
||||
return self.architecture_event(Event::Cancel);
|
||||
}
|
||||
}
|
||||
Event::Cancel => {
|
||||
self.architecture = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Cancelled · Select / move".into();
|
||||
}
|
||||
Event::Name(value) => {
|
||||
if value.len() <= 16384
|
||||
@@ -409,6 +375,13 @@ impl App {
|
||||
s.saved.page = page.min(1_000_000);
|
||||
}
|
||||
}
|
||||
Event::Edit => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.placing = false;
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::Pick(id) => {
|
||||
if !candidates(self)
|
||||
.iter()
|
||||
@@ -587,10 +560,9 @@ pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
|
||||
row![
|
||||
iced::widget::Space::new().width(x),
|
||||
iced::widget::opaque(
|
||||
iced::widget::container(scrollable(name_editor(app)))
|
||||
iced::widget::container(name_editor(app))
|
||||
.padding(10)
|
||||
.width(width)
|
||||
.max_height((size.height - y - 8.).max(0.))
|
||||
.style(crate::style::card)
|
||||
)
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
canvas::{self, Frame, Geometry, Path, Stroke},
|
||||
container, tooltip,
|
||||
column, container, tooltip,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -165,7 +165,7 @@ fn tool<'a>(
|
||||
.into()
|
||||
}
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let mut content = iced::widget::column![tool(
|
||||
let mut content = column![tool(
|
||||
app,
|
||||
Icon::Collapse(app.architecture_palette_expanded),
|
||||
if app.architecture_palette_expanded {
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn setup(layer: Layer) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.layer = layer;
|
||||
app.active_diagram = None;
|
||||
let _ = app.update(Message::AddDiagram);
|
||||
app.zoom = 0.75;
|
||||
app.pan = iced::Vector::new(37., -19.);
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Architecture(event));
|
||||
}
|
||||
fn create(app: &mut App, kind: Kind, name: &str) -> Id {
|
||||
send(app, Event::Start(kind, Some(Position { x: 120., y: 160. })));
|
||||
send(app, Event::Name(name.into()));
|
||||
send(app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
app.selected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_allocated_work_is_root_owned_and_one_exact_history_change() {
|
||||
for (layer, participant, work) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::Function),
|
||||
] {
|
||||
let mut app = setup(layer);
|
||||
let performer = create(&mut app, participant, "Control team");
|
||||
let before = app.document.bundle().clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let point = Position { x: 151., y: 221. };
|
||||
send(&mut app, Event::Allocate(work, performer, Some(point)));
|
||||
send(&mut app, Event::Name(" Report 雨 ".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
let created = app.selected;
|
||||
let element = &app.project().elements[&created];
|
||||
assert_eq!(element.kind(), work);
|
||||
assert_eq!(element.name, " Report 雨 ");
|
||||
assert_eq!(element.parent, None);
|
||||
assert!(app.project().relations.values().any(|r| {
|
||||
r.kind() == RelationKind::Allocation && r.source() == performer && r.target() == created
|
||||
}));
|
||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||
assert_eq!(d.nodes[&app.selected_node.unwrap()].position, point);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
use iced::widget::canvas::{Event as CanvasEvent, Program};
|
||||
use iced::{keyboard, mouse};
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Entity, "Original team");
|
||||
let before = app.document.bundle().clone();
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: element,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.is_none()
|
||||
);
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(iced::Point::new(150., 120.)),
|
||||
);
|
||||
let action = canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.unwrap();
|
||||
let message = action.into_inner().0.unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
assert!(matches!(&app.architecture.as_ref().unwrap().saved.intent,
|
||||
Intent::Rename { element: id, name } if *id == element && name == "Original team"));
|
||||
send(&mut app, Event::Name(" Renamed team 雨 ".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
assert_eq!(app.project().elements[&element].name, " Renamed team 雨 ");
|
||||
assert_eq!(app.project().diagrams, diagrams);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_blank_names_and_escape_never_accept_placeholder_or_rename() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Actor, "Dispatcher");
|
||||
let before = app.document.bundle().clone();
|
||||
let context = app.route_context().unwrap();
|
||||
for event in [
|
||||
Event::Start(Kind::Activity, Some(Position { x: 220., y: 330. })),
|
||||
Event::Rename(context, element, Position { x: 150., y: 200. }),
|
||||
] {
|
||||
// Every cancel changes the gesture context; use the current context for F2.
|
||||
let event = match event {
|
||||
Event::Rename(_, element, point) => {
|
||||
Event::Rename(app.route_context().unwrap(), element, point)
|
||||
}
|
||||
event => event,
|
||||
};
|
||||
send(&mut app, event);
|
||||
send(&mut app, Event::Name(" \t ".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
assert!(app.architecture.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
let before = app.document.bundle().clone();
|
||||
let context = app.route_context().unwrap();
|
||||
for point in [
|
||||
Position { x: f32::NAN, y: 0. },
|
||||
Position {
|
||||
x: 0.,
|
||||
y: f32::INFINITY,
|
||||
},
|
||||
] {
|
||||
send(&mut app, Event::Click(context, point));
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
assert!(draft.unnamed_tool());
|
||||
assert!(validate_saved(app.project(), &draft.saved).is_ok());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
send(&mut app, Event::Click(context, Position { x: 25., y: 30. }));
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
|
||||
for rename in [false, true] {
|
||||
for stale_document in [false, true] {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Entity, "Accepted team");
|
||||
let point = Position {
|
||||
x: 151.5,
|
||||
y: 227.25,
|
||||
};
|
||||
let event = if rename {
|
||||
Event::Rename(app.route_context().unwrap(), element, point)
|
||||
} else {
|
||||
Event::Start(Kind::Activity, Some(point))
|
||||
};
|
||||
send(&mut app, event);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Name(" Retained engineering name 雨 ".into()),
|
||||
);
|
||||
// Change only the binding. The accepted model/source must not be
|
||||
// rewritten or a rejected draft silently rebound to the new context.
|
||||
if stale_document {
|
||||
app.architecture.as_mut().unwrap().document = Id::new_v4();
|
||||
} else {
|
||||
app.architecture.as_mut().unwrap().revision = app.document.revision() + 1;
|
||||
}
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
let saved = serde_json::to_value(&draft.saved).unwrap();
|
||||
let binding = (draft.document, draft.revision);
|
||||
let model = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let camera = (app.zoom, app.pan);
|
||||
|
||||
for _ in 0..2 {
|
||||
send(&mut app, Event::Submit);
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
assert!(
|
||||
draft.inline(),
|
||||
"rename={rename}, stale_document={stale_document}"
|
||||
);
|
||||
assert!(inline_editor(&app).is_some());
|
||||
assert_eq!(draft.error.as_deref(), Some(app.status.as_str()));
|
||||
assert!(app.status.contains("cancel and restart"));
|
||||
assert_eq!(serde_json::to_value(&draft.saved).unwrap(), saved);
|
||||
assert_eq!((draft.document, draft.revision), binding);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
}
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
let fresh = create(&mut app, Kind::Actor, "Fresh context");
|
||||
assert_eq!(app.project().elements[&fresh].name, "Fresh context");
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
|
||||
use iced::keyboard;
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(named),
|
||||
modified_key: keyboard::Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn focused_text_input_escape(name: &str) -> Message {
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
|
||||
.id("architecture-name")
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(field.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
field
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let escape = key_event(iced::keyboard::key::Named::Escape);
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
field.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&escape,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"Iced consumes focused-input Escape"
|
||||
);
|
||||
assert!(
|
||||
!tree
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
);
|
||||
captured_escape(
|
||||
escape,
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
|
||||
for rename in [false, true] {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Actor, "Accepted dispatcher");
|
||||
let model = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let point = Position { x: 439., y: 328.2 };
|
||||
let event = if rename {
|
||||
Event::Rename(app.route_context().unwrap(), element, point)
|
||||
} else {
|
||||
Event::Start(Kind::Actor, Some(point))
|
||||
};
|
||||
send(&mut app, event);
|
||||
let name = " Unaccepted long engineering name 雨 ";
|
||||
send(&mut app, Event::Name(name.into()));
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
let message = focused_text_input_escape(name);
|
||||
let _ = app.update(message);
|
||||
assert!(app.architecture.is_none());
|
||||
assert!(inline_editor(&app).is_none());
|
||||
assert_eq!(app.status, "Cancelled · Select / move");
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
for event in [
|
||||
None,
|
||||
Some(Event::Start(Kind::Entity, None)),
|
||||
Some(Event::Existing(None)),
|
||||
] {
|
||||
send(&mut app, Event::Cancel);
|
||||
if let Some(event) = event {
|
||||
send(&mut app, event);
|
||||
}
|
||||
let saved = app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(&s.saved).unwrap());
|
||||
let before = app.document.bundle().clone();
|
||||
let status = app.status.clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
let message = focused_text_input_escape("Other field or existing-element search");
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.architecture
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(&s.saved).unwrap()),
|
||||
saved
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
assert!(
|
||||
captured_escape(
|
||||
key_event(iced::keyboard::key::Named::Escape),
|
||||
iced::event::Status::Ignored,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
captured_escape(
|
||||
key_event(iced::keyboard::key::Named::Enter),
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
@@ -173,12 +173,7 @@ impl App {
|
||||
self.behavior = restored.behavior;
|
||||
self.workflow = restored.workflow;
|
||||
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
|
||||
if let Some(layer) = self
|
||||
.behavior
|
||||
.creation
|
||||
.pending_layer()
|
||||
.or_else(|| self.project().layer_of(focus))
|
||||
{
|
||||
if let Some(layer) = self.project().layer_of(focus) {
|
||||
self.layer = layer;
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
|
||||
self.active_diagram = self.diagram_for(layer, focus);
|
||||
@@ -207,26 +202,7 @@ impl App {
|
||||
} else {
|
||||
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
|
||||
};
|
||||
let lint = self.request_lint();
|
||||
if self
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
|
||||
])
|
||||
} else {
|
||||
lint
|
||||
}
|
||||
self.request_lint()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,177 +331,6 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
// Mount real inputs, with focus initially in another control. This
|
||||
// verifies the returned operation targets the naming field by its ID.
|
||||
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
|
||||
iced::widget::text_input("Engineering name", "Retained name")
|
||||
.id("architecture-name")
|
||||
.on_input(|_| ()),
|
||||
iced::widget::text_input("Other control", "Other value")
|
||||
.id("unrelated-field")
|
||||
.on_input(|_| ())
|
||||
]
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(fields.as_widget());
|
||||
let node = fields.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
|
||||
);
|
||||
tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let mut operations = 0;
|
||||
let mut lint_completions = 0;
|
||||
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
|
||||
while let Some(action) = stream.next().await {
|
||||
match action {
|
||||
iced_runtime::Action::Widget(mut operation) => {
|
||||
operations += 1;
|
||||
fields.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&(),
|
||||
operation.as_mut(),
|
||||
);
|
||||
}
|
||||
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
|
||||
lint_completions += 1;
|
||||
let _ = app.update(message);
|
||||
}
|
||||
_ => panic!("Unexpected recovery task action"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
lint_completions, 1,
|
||||
"Autofocus must not replace request_lint"
|
||||
);
|
||||
assert!(app.lint_running.is_none());
|
||||
let focused = |index: usize| {
|
||||
tree.children[index]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
};
|
||||
(operations, focused(0), focused(1))
|
||||
}
|
||||
|
||||
fn architecture_recovery_app() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let _ = app.update(Message::AddDiagram);
|
||||
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
|
||||
Kind::Entity,
|
||||
Some(Position { x: 80., y: 90. }),
|
||||
)));
|
||||
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
|
||||
"Accepted team".into(),
|
||||
)));
|
||||
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
|
||||
assert!(app.architecture.is_none());
|
||||
app
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
|
||||
{
|
||||
use architecture_authoring::Event;
|
||||
for rename in [false, true] {
|
||||
let mut app = architecture_recovery_app();
|
||||
let point = Position { x: 439., y: 328.2 };
|
||||
let event = if rename {
|
||||
Event::Rename(app.route_context().unwrap(), app.selected, point)
|
||||
} else {
|
||||
Event::Start(Kind::Actor, Some(point))
|
||||
};
|
||||
let _ = app.update(Message::Architecture(event));
|
||||
let _ = app.update(Message::Architecture(Event::Name(
|
||||
" Recovered engineering name 雨 ".into(),
|
||||
)));
|
||||
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
|
||||
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
|
||||
let expected = snapshot.clone().restore().unwrap();
|
||||
let checkpoint = snapshot.id;
|
||||
let mut recovered = App::with_project(Project::empty());
|
||||
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
||||
assert_eq!(
|
||||
run_recovery_tasks(&mut recovered, task).await,
|
||||
(1, true, false)
|
||||
);
|
||||
assert_eq!(recovered.page, Page::Diagram);
|
||||
assert!(recovered.architecture.as_ref().unwrap().inline());
|
||||
assert_eq!(
|
||||
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
|
||||
saved
|
||||
);
|
||||
assert_eq!(recovered.project(), expected.document.project());
|
||||
assert_eq!(recovered.document.bundle(), expected.document.bundle());
|
||||
assert_eq!(recovered.document.revision(), expected.document.revision());
|
||||
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
|
||||
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
|
||||
assert_eq!(
|
||||
recovered.source_workspace.bundle(),
|
||||
expected.workspace.bundle()
|
||||
);
|
||||
assert_eq!(recovered.recovery.restored, Some(checkpoint));
|
||||
assert!(recovered.path.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
|
||||
use architecture_authoring::Event;
|
||||
for context in ["accepted", "armed", "picker", "property", "source"] {
|
||||
let mut app = architecture_recovery_app();
|
||||
match context {
|
||||
"armed" => {
|
||||
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
|
||||
}
|
||||
"picker" => {
|
||||
let _ = app.update(Message::Architecture(Event::Existing(None)));
|
||||
}
|
||||
"property" => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied property".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
"source" => {
|
||||
app.source_workspace
|
||||
.edit("incomplete source 雨".into(), app.source_editor.cursor());
|
||||
assert!(app.source_dirty());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
|
||||
let expected = snapshot.clone().restore().unwrap();
|
||||
let mut recovered = App::with_project(Project::empty());
|
||||
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
||||
assert_eq!(
|
||||
run_recovery_tasks(&mut recovered, task).await,
|
||||
(0, false, true),
|
||||
"{context}"
|
||||
);
|
||||
assert!(
|
||||
!recovered
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
);
|
||||
assert_eq!(recovered.project(), expected.document.project());
|
||||
assert_eq!(recovered.document.bundle(), expected.document.bundle());
|
||||
assert_eq!(recovered.document.revision(), expected.document.revision());
|
||||
assert_eq!(
|
||||
recovered.source_workspace.bundle(),
|
||||
expected.workspace.bundle()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -213,14 +213,6 @@ impl App {
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
resource_id: remote.resource_id,
|
||||
@@ -353,14 +345,6 @@ impl App {
|
||||
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||
Ok(_) => {
|
||||
self.document.mark_saved(remote.compiled.clone());
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
resource_id: remote.resource_id,
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,219 +0,0 @@
|
||||
//! Keep Iced's single-line input from silently filtering pasted control bytes.
|
||||
//! Only the alias field uses this adapter. The child retains editing and focus.
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
|
||||
};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(AliasInput { input, rejected })
|
||||
}
|
||||
|
||||
struct AliasInput<'a, Message, Theme, R> {
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
|
||||
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
|
||||
// previously admitted paste while V is held, until KeyReleased clears that cache.
|
||||
struct CapturedClipboard<'a> {
|
||||
content: Option<String>,
|
||||
original: &'a mut dyn Clipboard,
|
||||
}
|
||||
impl Clipboard for CapturedClipboard<'_> {
|
||||
fn read(&self, kind: clipboard::Kind) -> Option<String> {
|
||||
if kind == clipboard::Kind::Standard {
|
||||
self.content.clone()
|
||||
} else {
|
||||
self.original.read(kind)
|
||||
}
|
||||
}
|
||||
fn write(&mut self, kind: clipboard::Kind, contents: String) {
|
||||
self.original.write(kind, contents);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
|
||||
for AliasInput<'_, Message, Theme, R>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.input.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.input.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(self.input.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.input));
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.input.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let focused = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused();
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
|
||||
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
}
|
||||
let paste = matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
|
||||
if key.to_latin(*physical_key) == Some('v')
|
||||
&& state.modifiers.command() && !state.modifiers.alt());
|
||||
if focused
|
||||
&& paste
|
||||
&& let Some(rejected) = &self.rejected
|
||||
{
|
||||
let content = clipboard.read(clipboard::Kind::Standard);
|
||||
if content
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.chars().any(char::is_control))
|
||||
{
|
||||
// Do not invoke the child: even an empty replacement would erase
|
||||
// its selection. Approval invalidation is an explicit App event.
|
||||
shell.publish(rejected.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
&mut CapturedClipboard {
|
||||
content,
|
||||
original: clipboard,
|
||||
},
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
|
||||
// Escape branch, which also resets stored modifiers. Observe that branch
|
||||
// after its text/shortcut early returns and effective-key conversion.
|
||||
if focused
|
||||
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
|
||||
&& !tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused()
|
||||
{
|
||||
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.input.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -1,220 +0,0 @@
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::keyboard::{
|
||||
Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Signal {
|
||||
Input(String),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
struct ClipboardText {
|
||||
text: String,
|
||||
reads: Cell<usize>,
|
||||
}
|
||||
impl Clipboard for ClipboardText {
|
||||
fn read(&self, _: clipboard::Kind) -> Option<String> {
|
||||
self.reads.set(self.reads.get() + 1);
|
||||
Some(self.text.clone())
|
||||
}
|
||||
fn write(&mut self, _: clipboard::Kind, _: String) {}
|
||||
}
|
||||
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
|
||||
|
||||
struct Mounted {
|
||||
input: Element<'static, Signal, iced::Theme, FontLayout>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
clipboard: ClipboardText,
|
||||
messages: Vec<Signal>,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
|
||||
let mut child = iced::widget::text_input("Alias", "send_water");
|
||||
if enabled {
|
||||
child = child.on_input(Signal::Input);
|
||||
}
|
||||
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let node = input.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
|
||||
);
|
||||
if focused {
|
||||
let state = tree.children[0].state.downcast_mut::<InputState>();
|
||||
state.focus();
|
||||
state.select_range(0, 10);
|
||||
}
|
||||
let mut mounted = Self {
|
||||
input,
|
||||
tree,
|
||||
node,
|
||||
clipboard: ClipboardText {
|
||||
text: "foo\nbar".into(),
|
||||
reads: Cell::new(0),
|
||||
},
|
||||
messages: vec![],
|
||||
};
|
||||
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
modifiers,
|
||||
)));
|
||||
mounted
|
||||
}
|
||||
fn state(&self) -> &InputState {
|
||||
self.tree.children[0].state.downcast_ref::<InputState>()
|
||||
}
|
||||
fn event(&mut self, event: Event) -> bool {
|
||||
let mut shell = Shell::new(&mut self.messages);
|
||||
self.input.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
|
||||
);
|
||||
shell.is_event_captured()
|
||||
}
|
||||
}
|
||||
fn key(key: Key, modified_key: Key, code: Code) -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key: Physical::Code(code),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn paste() -> Event {
|
||||
key(
|
||||
Key::Character("v".into()),
|
||||
Key::Character("v".into()),
|
||||
Code::KeyV,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
|
||||
for focused in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::COMMAND,
|
||||
Modifiers::COMMAND | Modifiers::ALT,
|
||||
Modifiers::COMMAND | Modifiers::SHIFT,
|
||||
] {
|
||||
let mut mounted = Mounted::new(focused, enabled, modifiers);
|
||||
let before = mounted.state().cursor();
|
||||
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
|
||||
let captured = mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
if expected {
|
||||
vec![Signal::Rejected]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
if expected {
|
||||
assert!(captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_escape_reset_follows_child_after_early_returns() {
|
||||
// Raw Escape mapped to Home must not clear only the guard's Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Escape),
|
||||
Key::Named(Named::Home),
|
||||
Code::Escape,
|
||||
));
|
||||
assert!(mounted.state().is_focused());
|
||||
assert!(mounted.event(paste()));
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
|
||||
// Effective Escape clears both states; a later focus operation does not
|
||||
// invent a new ModifiersChanged event or revive the previous Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
));
|
||||
assert!(!mounted.state().is_focused());
|
||||
mounted.tree.children[0]
|
||||
.state
|
||||
.downcast_mut::<InputState>()
|
||||
.focus();
|
||||
mounted.event(paste());
|
||||
assert!(mounted.messages.is_empty());
|
||||
assert_eq!(mounted.clipboard.reads.get(), 0);
|
||||
|
||||
// Printable text returns before effective Escape and leaves modifiers live.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
let mut event = key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
);
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
|
||||
*text = Some("x".into());
|
||||
}
|
||||
mounted.event(event);
|
||||
assert!(mounted.state().is_focused());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_paste_cache_contains_only_previously_admitted_text() {
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.clipboard.text = "water".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.clipboard.text = "other".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwater".into())],
|
||||
"Iced reuses its first admitted paste while the key is held"
|
||||
);
|
||||
mounted.messages.clear();
|
||||
let before = mounted.state().cursor();
|
||||
mounted.clipboard.text = "bad\tvalue".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
mounted.messages.clear();
|
||||
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
key: Key::Character("v".into()),
|
||||
modified_key: Key::Character("v".into()),
|
||||
physical_key: Physical::Code(Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
}));
|
||||
mounted.clipboard.text = "later".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwaterlater".into())]
|
||||
);
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
//! Display-only reuse after a guarded alias rename. This is not model authority.
|
||||
use iced::{advanced::text::Renderer, widget::text_editor};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Replace exact, unchanged-line-count source in an existing editor.
|
||||
///
|
||||
/// The caller must first authorize/commit the model change and must reconstruct
|
||||
/// from accepted source on false. Preflight failures do not mutate the editor;
|
||||
/// a failed final readback can leave it changed. On changing success the cursor
|
||||
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
|
||||
/// Direct Content actions deliberately bypass App draft edits and Undo history.
|
||||
pub(super) fn replace<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
before: &str,
|
||||
after: &str,
|
||||
) -> bool {
|
||||
if editor.text() != before {
|
||||
return false;
|
||||
}
|
||||
let Some(old_lines) = lines(before) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new_lines) = lines(after) else {
|
||||
return false;
|
||||
};
|
||||
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
|
||||
return false;
|
||||
}
|
||||
let mut patches = Vec::new();
|
||||
for (line, ((old, old_ending), (new, new_ending))) in
|
||||
old_lines.into_iter().zip(new_lines).enumerate()
|
||||
{
|
||||
let Some(actual) = editor.line(line) else {
|
||||
return false;
|
||||
};
|
||||
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
|
||||
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
|
||||
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
|
||||
return false;
|
||||
}
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
let mut start = old
|
||||
.bytes()
|
||||
.zip(new.bytes())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let mut suffix = old[start..]
|
||||
.bytes()
|
||||
.rev()
|
||||
.zip(new[start..].bytes().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
|
||||
{
|
||||
suffix -= 1;
|
||||
}
|
||||
// All positions and owned payloads are prepared before the first edit.
|
||||
patches.push((
|
||||
line,
|
||||
start,
|
||||
old.len() - suffix,
|
||||
Arc::new(new[start..new.len() - suffix].to_owned()),
|
||||
));
|
||||
}
|
||||
if patches.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let empty = Arc::new(String::new());
|
||||
for (line, start, end, replacement) in patches.into_iter().rev() {
|
||||
select(editor, line, start, end);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
replacement,
|
||||
)));
|
||||
}
|
||||
// Iced overwrites its earliest-change marker on each Edit. An explicit
|
||||
// zero-width selection + empty Paste changes no bytes, but marks line zero.
|
||||
// This also refreshes previously unhighlighted lines before the first patch.
|
||||
// It resets only line zero's text cache; other unchanged lines retain theirs.
|
||||
select(editor, 0, 0, 0);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
|
||||
editor.text() == after
|
||||
}
|
||||
|
||||
fn select<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
line: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) {
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line,
|
||||
column: start,
|
||||
},
|
||||
// move_to(None) does not clear a previous selection in this Iced version.
|
||||
selection: Some(text_editor::Position { line, column: end }),
|
||||
});
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
|
||||
use text_editor::LineEnding;
|
||||
let mut result = Vec::new();
|
||||
let mut parts = source.split('\n').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
let (text, ending) = if parts.peek().is_none() {
|
||||
(part, LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, LineEnding::CrLf)
|
||||
} else {
|
||||
(part, LineEnding::Lf)
|
||||
};
|
||||
if text.contains('\r')
|
||||
|| (text.is_empty()
|
||||
&& ending == LineEnding::CrLf
|
||||
&& result
|
||||
.last()
|
||||
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
result.push((text, ending));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{layout, text::Highlighter, widget::Tree};
|
||||
use iced::{Element, Font, Length, Size};
|
||||
use std::{cell::RefCell, ops::Range};
|
||||
|
||||
#[test]
|
||||
fn exact_text_matrix_and_reverse_preserve_endings() {
|
||||
for (before, after) in [
|
||||
("", ""),
|
||||
("", "alias"),
|
||||
("alias", ""),
|
||||
("same", "same"),
|
||||
("a\n", "alias\n"),
|
||||
("a\r\n", "alias\r\n"),
|
||||
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
|
||||
("\na\n\n", "\nlong_alias\n\n"),
|
||||
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
|
||||
(
|
||||
"雨🙂 e\u{301} @a @a // a",
|
||||
"雨🙂 e\u{301} @a_long @a_long // a",
|
||||
),
|
||||
("@abc", "@abcdef"),
|
||||
("@abcdef", "@abc"),
|
||||
("@abc_suffix", "@def_suffix"),
|
||||
("@none", "@identifier"),
|
||||
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
|
||||
// not be used as String split positions, even outside alias scope.
|
||||
("雨xé", "雪xê"),
|
||||
("é", "©"),
|
||||
(
|
||||
"unchanged\n@a\nunchanged\n@a\n",
|
||||
"unchanged\n@long\nunchanged\n@long\n",
|
||||
),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
assert!(
|
||||
replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), after);
|
||||
let expected = text_editor::Content::<FontLayout>::with_text(after);
|
||||
assert_eq!(editor.line_count(), expected.line_count());
|
||||
for (actual, expected) in editor.lines().zip(expected.lines()) {
|
||||
assert_eq!(actual.text, expected.text);
|
||||
assert_eq!(actual.ending, expected.ending);
|
||||
}
|
||||
if before != after {
|
||||
assert_eq!(
|
||||
editor.cursor().position,
|
||||
text_editor::Position { line: 0, column: 0 }
|
||||
);
|
||||
assert_eq!(editor.cursor().selection, None);
|
||||
}
|
||||
assert!(replace(&mut editor, after, before));
|
||||
assert_eq!(editor.text(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
|
||||
for (actual, before, after) in [
|
||||
("live", "stale", "changed"),
|
||||
("a\nb", "a\nb", "long\nb\nc"),
|
||||
("a\nb", "a\nb", "long\r\nb"),
|
||||
// Refusal on a later line must not apply the earlier planned edit.
|
||||
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
|
||||
("a\rb", "a\rb", "long\rb"),
|
||||
("a\n\rb", "a\n\rb", "long\n\rb"),
|
||||
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
|
||||
// The replacement must not introduce a greedy LFCR boundary either.
|
||||
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
|
||||
("a", "a", "long\r"),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
|
||||
select(&mut editor, 0, 0, 1);
|
||||
let cursor = editor.cursor();
|
||||
let selection = editor.selection();
|
||||
assert!(
|
||||
!replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), actual);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.selection(), selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
|
||||
select(&mut editor, 0, 1, 2);
|
||||
let cursor = editor.cursor();
|
||||
assert!(replace(&mut editor, "@a", "@a"));
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert!(replace(&mut editor, "@a", "@long"));
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
|
||||
assert_eq!(editor.text(), "@long!");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Tracked(crate::highlight::ArcadiaHighlighter);
|
||||
impl Highlighter for Tracked {
|
||||
type Settings = Arc<str>;
|
||||
type Highlight = oa_core::language::TokenClass;
|
||||
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
|
||||
fn new(settings: &Self::Settings) -> Self {
|
||||
Self(crate::highlight::ArcadiaHighlighter::new(settings))
|
||||
}
|
||||
fn update(&mut self, settings: &Self::Settings) {
|
||||
self.0.update(settings);
|
||||
}
|
||||
fn change_line(&mut self, line: usize) {
|
||||
CHANGES.with(|changes| changes.borrow_mut().push(line));
|
||||
self.0.change_line(line);
|
||||
}
|
||||
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
|
||||
self.0.highlight_line(line)
|
||||
}
|
||||
fn current_line(&self) -> usize {
|
||||
self.0.current_line()
|
||||
}
|
||||
}
|
||||
|
||||
fn view(
|
||||
editor: &text_editor::Content<FontLayout>,
|
||||
source: Arc<str>,
|
||||
) -> Element<'_, (), iced::Theme, FontLayout> {
|
||||
text_editor(editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<Tracked>(source, crate::highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
|
||||
let before = "// earlier\n// unmodified\n@a";
|
||||
let after = "// earlier\n// unmodified\n@none";
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
tree
|
||||
};
|
||||
CHANGES.with(|changes| changes.borrow_mut().clear());
|
||||
assert!(replace(&mut editor, before, after));
|
||||
{
|
||||
let mut widget = view(&editor, Arc::from(after));
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
}
|
||||
// Font/metrics/bounds are already stable. This notification must come
|
||||
// from the helper's empty edit, not a fresh renderer initialization.
|
||||
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
|
||||
assert_eq!(editor.text(), after);
|
||||
}
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
//! Real paragraph advances and clipping through the actual preview dialog.
|
||||
//! Native screenshots additionally check raster ink and the platform renderer.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
|
||||
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct Glyph {
|
||||
text: String,
|
||||
paragraph_y: u32,
|
||||
line: usize,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
|
||||
struct GlyphRecorder {
|
||||
wanted: BTreeSet<String>,
|
||||
transforms: Vec<Transformation>,
|
||||
clips: Vec<Rectangle>,
|
||||
glyphs: Vec<Glyph>,
|
||||
quads: Vec<Rectangle>,
|
||||
}
|
||||
impl GlyphRecorder {
|
||||
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
|
||||
Self {
|
||||
wanted,
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clips: vec![viewport],
|
||||
glyphs: vec![],
|
||||
quads: vec![],
|
||||
}
|
||||
}
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for GlyphRecorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clips = vec![bounds];
|
||||
self.glyphs.clear();
|
||||
self.quads.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
|
||||
self.quads.push(quad.bounds * self.transform());
|
||||
}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for GlyphRecorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let text = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !self.wanted.contains(&text) {
|
||||
return;
|
||||
}
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
if run.text[glyph.start..glyph.end]
|
||||
.chars()
|
||||
.all(char::is_whitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bounds = Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform();
|
||||
self.glyphs.push(Glyph {
|
||||
text: text.clone(),
|
||||
paragraph_y: position.y.to_bits(),
|
||||
line: run.line_i,
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds,
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(
|
||||
&mut self,
|
||||
editor: &Self::Editor,
|
||||
position: Point,
|
||||
color: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
|
||||
}
|
||||
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
|
||||
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollGeometry {
|
||||
id: Option<widget::Id>,
|
||||
bounds: Option<Rectangle>,
|
||||
content: Option<Rectangle>,
|
||||
offset: Vector,
|
||||
}
|
||||
impl widget::Operation for ScrollGeometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == self.id.as_ref() {
|
||||
self.bounds = Some(bounds);
|
||||
self.content = Some(content);
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn normalized(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
|
||||
text.split('\n')
|
||||
.enumerate()
|
||||
.flat_map(|(line, text)| {
|
||||
text.char_indices()
|
||||
.filter(|(_, ch)| !ch.is_whitespace())
|
||||
.map(move |(byte, _)| (line, byte))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declarations(long: bool) -> App {
|
||||
let name = if long {
|
||||
format!("Assess {} request", "界🚀".repeat(90))
|
||||
} else {
|
||||
"Assess 救援 request".into()
|
||||
};
|
||||
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
|
||||
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
|
||||
]) };
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "救援/assess.arcadia".into();
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.show_source_file(false);
|
||||
let start = app
|
||||
.source_workspace
|
||||
.text()
|
||||
.find("oa_activity assess")
|
||||
.unwrap()
|
||||
+ "oa_activity ".len();
|
||||
app.source_editor.move_to(cursor(
|
||||
app.source_workspace.text(),
|
||||
start,
|
||||
start + "assess".len(),
|
||||
));
|
||||
begin(&mut app);
|
||||
let replacement = if long {
|
||||
"a".repeat(255)
|
||||
} else {
|
||||
"assess_damage".into()
|
||||
};
|
||||
input(&mut app, &replacement);
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn assert_page_glyphs_visible(app: &App, host: Size) {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let ready = form.approval.as_ref().unwrap();
|
||||
let file = &ready.preview.files()[form.file];
|
||||
let mut expected = BTreeMap::<String, usize>::new();
|
||||
for line in file.hunks()[form.hunk]
|
||||
.lines()
|
||||
.iter()
|
||||
.skip(form.line)
|
||||
.take(LINES_PER_PAGE)
|
||||
{
|
||||
for source in [
|
||||
line.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
line.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
] {
|
||||
if let Some(fragment) = preview::text_segment(source, form.segment) {
|
||||
let text = normalized(fragment);
|
||||
if !required_chars(&text).is_empty() {
|
||||
*expected.entry(text).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
let viewport = Rectangle::new(Point::ORIGIN, host);
|
||||
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
|
||||
let editor = text_editor::Content::<GlyphRecorder>::new();
|
||||
let mut view = dialog_content(app, &editor);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
|
||||
let mut geometry = ScrollGeometry {
|
||||
id: Some(preview_scroll_id(form, ready.id)),
|
||||
..ScrollGeometry::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
let bounds = geometry.bounds.unwrap();
|
||||
let content = geometry.content.unwrap();
|
||||
assert!(bounds.height >= 40.);
|
||||
let maximum = (content.height - bounds.height).max(0.);
|
||||
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
|
||||
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
|
||||
let mut reserved_bar_gap = true;
|
||||
for step in 0..=steps {
|
||||
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
assert!(
|
||||
(geometry.offset.y - offset).abs() <= 0.5,
|
||||
"actual scroll operation reaches the requested source offset"
|
||||
);
|
||||
renderer::Renderer::reset(&mut renderer, viewport);
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
// The vertical scrollbar's actual painted thumb fixes its excluded x
|
||||
// region; the outer scroll clip alone cannot detect overpainting.
|
||||
let scrollbar_left = renderer
|
||||
.quads
|
||||
.iter()
|
||||
.filter(|quad| {
|
||||
quad.width > 0.
|
||||
&& quad.width <= 16.
|
||||
&& quad.height > 0.
|
||||
&& quad.x >= bounds.x + bounds.width - 18.
|
||||
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
|
||||
&& quad.y >= bounds.y - 0.5
|
||||
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
|
||||
})
|
||||
.map(|quad| quad.x)
|
||||
.min_by(f32::total_cmp);
|
||||
if maximum > 0. {
|
||||
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
|
||||
reserved_bar_gap &= content.x + content.width <= left - 7.5;
|
||||
}
|
||||
for glyph in &renderer.glyphs {
|
||||
let right = glyph.bounds.x + glyph.bounds.width;
|
||||
if let Some(left) = scrollbar_left {
|
||||
assert!(
|
||||
right <= left - 0.5,
|
||||
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
|
||||
glyph.text,
|
||||
glyph.bytes,
|
||||
glyph.bounds
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
|
||||
"source glyph escaped reserved width: {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds
|
||||
);
|
||||
assert!(
|
||||
glyph.bounds.x >= glyph.clip.x - 0.5
|
||||
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
|
||||
"source glyph horizontally clipped: {:?} {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds,
|
||||
glyph.clip
|
||||
);
|
||||
if glyph.bounds.y >= glyph.clip.y - 0.5
|
||||
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
|
||||
{
|
||||
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
|
||||
let chars = logical_line
|
||||
.char_indices()
|
||||
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
|
||||
.map(|(byte, _)| (glyph.line, byte));
|
||||
visible
|
||||
.entry((glyph.text.clone(), glyph.paragraph_y))
|
||||
.or_default()
|
||||
.extend(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reserved_bar_gap,
|
||||
"source content must reserve actual scrollbar width plus gap"
|
||||
);
|
||||
assert!(
|
||||
bounds.width >= host.width - 30.,
|
||||
"preview must use available dialog width: {bounds:?} / {host:?}"
|
||||
);
|
||||
for (text, count) in expected {
|
||||
let required = required_chars(&text);
|
||||
let instances: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|((value, _), _)| value == &text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
instances.len(),
|
||||
count,
|
||||
"all before/after occurrences must be seen: {text:?}"
|
||||
);
|
||||
for (_, seen) in instances {
|
||||
assert_eq!(
|
||||
seen, &required,
|
||||
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
|
||||
for long in [false, true] {
|
||||
let mut app = declarations(long);
|
||||
let ready = app
|
||||
.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
let before = evidence(&app);
|
||||
for (width, height) in [(1100., 700.), (580., 700.)] {
|
||||
app.window_size = Size::new(width, height);
|
||||
let host = Size::new((width - 84.).min(708.), height - 120.);
|
||||
let total = segment_total(app.source_rename.form.as_ref().unwrap());
|
||||
if long {
|
||||
assert!(
|
||||
total >= 3,
|
||||
"include unbroken ASCII and wide Unicode across segments"
|
||||
);
|
||||
}
|
||||
for segment in 0..total {
|
||||
assert_page_glyphs_visible(&app, host);
|
||||
if segment + 1 < total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, true));
|
||||
}
|
||||
}
|
||||
for _ in 1..total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, false));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
evidence(&app),
|
||||
before,
|
||||
"preview geometry/paging cannot alter transaction inputs"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
ready.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
//! Execute the controller's scroll operations on a retained, scrolled dialog.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
const HOST: Size = Size::new(720., 660.);
|
||||
|
||||
fn paged_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let name = "Long engineering name 雨 ".repeat(18);
|
||||
source.files.get_mut("defs.arcadia").unwrap().replace_range(
|
||||
..,
|
||||
&format!(
|
||||
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
|
||||
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
|
||||
),
|
||||
);
|
||||
let mut uses = String::new();
|
||||
for index in 0..16 {
|
||||
if index == 12 {
|
||||
uses.push_str(&"// unchanged gap\r\n".repeat(10));
|
||||
}
|
||||
uses.push_str(&format!(
|
||||
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
|
||||
));
|
||||
}
|
||||
source.files.insert("uses.arcadia".into(), uses);
|
||||
let mut app = fixture();
|
||||
app.window_size = HOST;
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
|
||||
app.source_editor
|
||||
.move_to(cursor(app.source_workspace.text(), start, start + 4));
|
||||
begin(&mut app);
|
||||
input(&mut app, "pump_long");
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn scroll_id(app: &App) -> widget::Id {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
|
||||
}
|
||||
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
editor: text_editor::Content<FontLayout>,
|
||||
tree: widget::Tree,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(app: &App) -> Self {
|
||||
let editor = text_editor::Content::<FontLayout>::new();
|
||||
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
|
||||
Self { editor, tree }
|
||||
}
|
||||
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
// Preserve the actual scroll state across the new page's view diff.
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
|
||||
}
|
||||
fn offset(&mut self, app: &App) -> f32 {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
}
|
||||
fn scroll_down(&mut self, app: &App) -> f32 {
|
||||
self.operate(
|
||||
app,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
scroll_id(app),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(10_000.),
|
||||
},
|
||||
),
|
||||
);
|
||||
let offset = self.offset(app);
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
||||
let mut count = 0;
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("preview paging must only schedule a widget operation");
|
||||
};
|
||||
self.operate(app, operation.as_mut());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let old_id = scroll_id(&app);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_ne!(old_id, scroll_id(&app));
|
||||
// Negative control: even after diff/layout with the new id and new file,
|
||||
// Iced retains the previous offset until the returned operation executes.
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
|
||||
for navigation in [
|
||||
Event::Line(invocation, approval, true),
|
||||
Event::Segment(invocation, approval, true),
|
||||
Event::Hunk(invocation, approval, true),
|
||||
Event::File(invocation, approval, false),
|
||||
Event::Search(invocation, "uses".into()),
|
||||
Event::Search(invocation, "no-path-match".into()),
|
||||
Event::Search(invocation, String::new()),
|
||||
] {
|
||||
mounted.scroll_down(&app);
|
||||
let task = app.update(Message::SourceRename(navigation));
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
// Passive redraw and unchanged filter input preserve deliberate scrolling.
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
String::new(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Input(
|
||||
invocation,
|
||||
"pump_long".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"clamped first-page request is a no-op"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let old_approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let old_page_task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
let next_page_task = app.update(Message::SourceRename(Event::Line(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
|
||||
|
||||
let old_approval_task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"defs".into(),
|
||||
)));
|
||||
input(&mut app, "pump_new");
|
||||
let _ = preview(&mut app); // Real new approval in the same invocation/page.
|
||||
assert_ne!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
old_approval
|
||||
);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old approval target is absent"
|
||||
);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"stale envelope is rejected before scheduling"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
|
||||
let first_visit = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let next_page = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page).await, 1);
|
||||
let return_visit = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, return_visit).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, first_visit).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old visit to the identical page is stale"
|
||||
);
|
||||
|
||||
let old_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"uses".into(),
|
||||
)));
|
||||
let new_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"us".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, new_filter).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_filter).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old filter on the same page is stale"
|
||||
);
|
||||
|
||||
input(&mut app, "pump_new");
|
||||
let job = start_preview(&mut app);
|
||||
let completion = preview_completion(&app, job);
|
||||
// No intermediate frame mounts the temporary no-approval view. The old
|
||||
// Scrollable survives even though the new approval has a different id.
|
||||
let ready = app.update(Message::SourceRenameReady(completion));
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, ready).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
|
||||
//! Child of source_rename_timing_tests, preserving the separate real App test.
|
||||
use super::*;
|
||||
use crate::{highlight, views::source_tests::FontLayout};
|
||||
use iced::advanced::widget::operation::Focusable;
|
||||
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{
|
||||
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
|
||||
};
|
||||
|
||||
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
|
||||
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
|
||||
#[derive(Default)]
|
||||
struct ObservedFontLayout {
|
||||
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
|
||||
}
|
||||
impl renderer::Renderer for ObservedFontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {
|
||||
self.drawn = None;
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for ObservedFontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
|
||||
self.drawn = Some((editor.downgrade(), position, clip));
|
||||
}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
type Content = text_editor::Content<ObservedFontLayout>;
|
||||
const HOST: Size = Size::new(640., 350.);
|
||||
|
||||
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
|
||||
text_editor(editor)
|
||||
.on_action(|_| ())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
|
||||
assert_eq!(editor.text(), expected);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
let lines: Vec<_> = expected.split('\n').collect();
|
||||
assert_eq!(editor.line_count(), lines.len());
|
||||
for (index, part) in lines.iter().enumerate() {
|
||||
let (text, ending) = if index + 1 == lines.len() {
|
||||
(*part, text_editor::LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, text_editor::LineEnding::CrLf)
|
||||
} else {
|
||||
(*part, text_editor::LineEnding::Lf)
|
||||
};
|
||||
let actual = editor.line(index).unwrap();
|
||||
assert_eq!(actual.text, text);
|
||||
assert_eq!(actual.ending, ending);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
|
||||
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
|
||||
let editor = weak.upgrade().expect("drawn editor is still live");
|
||||
let buffer = editor.buffer();
|
||||
assert_eq!(weak.bounds, Size::new(608., 318.));
|
||||
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
|
||||
assert_eq!(buffer.metrics().font_size, 14.);
|
||||
assert_eq!(
|
||||
buffer.scroll().horizontal,
|
||||
0.,
|
||||
"fixture origin fits the viewport"
|
||||
);
|
||||
let start = cursor.position;
|
||||
let end = cursor.selection.expect("approved alias selection");
|
||||
assert_eq!(start.line, end.line);
|
||||
assert_eq!(end.column - start.column, alias.len());
|
||||
let line = &buffer.lines[start.line];
|
||||
assert_eq!(&line.text()[start.column..end.column], alias);
|
||||
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
|
||||
let font = graphics::text::to_attributes(Font::MONOSPACE);
|
||||
for byte in start.column..end.column {
|
||||
let attrs = line.attrs_list().get_span(byte);
|
||||
assert_eq!(
|
||||
attrs.color_opt,
|
||||
Some(color),
|
||||
"accepted alias syntax attributes"
|
||||
);
|
||||
assert_eq!(attrs.family, font.family);
|
||||
}
|
||||
let mut covered = vec![false; alias.len()];
|
||||
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
|
||||
for glyph in run.glyphs {
|
||||
let from = glyph.start.max(start.column);
|
||||
let to = glyph.end.min(end.column);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
|
||||
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
|
||||
let y = position.y + run.line_top;
|
||||
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
|
||||
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
|
||||
covered[from - start.column..to - start.column].fill(true);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
covered.into_iter().all(|seen| seen),
|
||||
"entire alias has visible shaped glyphs"
|
||||
);
|
||||
// This temporary strong upgrade is dropped before the next editor mutation.
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Arm {
|
||||
Constructor,
|
||||
Reuse,
|
||||
}
|
||||
impl Arm {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Constructor => "with_text",
|
||||
Self::Reuse => "same_line_reuse",
|
||||
}
|
||||
}
|
||||
fn phases(self) -> [&'static str; 4] {
|
||||
match self {
|
||||
Self::Constructor => [
|
||||
"constructor_replace_origin",
|
||||
"constructor_next_layout",
|
||||
"constructor_highlight_draw",
|
||||
"constructor_combined",
|
||||
],
|
||||
Self::Reuse => [
|
||||
"reuse_replace_origin",
|
||||
"reuse_next_layout",
|
||||
"reuse_highlight_draw",
|
||||
"reuse_combined",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trial(
|
||||
fixture: &Fixture,
|
||||
cursor: text_editor::Cursor,
|
||||
arm: Arm,
|
||||
trial: usize,
|
||||
samples: &mut Samples,
|
||||
) {
|
||||
let before = &fixture.bundle.files[ORIGIN];
|
||||
let after = &fixture.expected_bundle.files[ORIGIN];
|
||||
let mut editor = Content::with_text(before);
|
||||
editor.move_to(fixture.cursor);
|
||||
let mut renderer = ObservedFontLayout::default();
|
||||
let theme = iced::Theme::Light;
|
||||
let defaults = renderer::Style::default();
|
||||
let viewport = Rectangle::with_size(HOST);
|
||||
let limits = layout::Limits::new(Size::ZERO, HOST);
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before.as_str()));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
tree.state
|
||||
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
|
||||
.focus();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
tree
|
||||
};
|
||||
check_text(&editor, before, fixture.cursor);
|
||||
check_draw(&renderer, fixture.cursor, OLD);
|
||||
// No recorder Weak/Strong from setup may perturb replacement ownership.
|
||||
renderer.drawn = None;
|
||||
let next_source: Arc<str> = Arc::from(after.as_str());
|
||||
|
||||
// Only normal operations and four timestamps occur between start and end.
|
||||
// Source/model oracles, logging and recorder glyph inspection are outside.
|
||||
let started = Instant::now();
|
||||
let replaced = match arm {
|
||||
Arm::Constructor => {
|
||||
editor = Content::with_text(after);
|
||||
true
|
||||
}
|
||||
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
|
||||
};
|
||||
editor.move_to(cursor);
|
||||
let replaced_at = Instant::now();
|
||||
let mut widget = view(&editor, next_source);
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
let laid_out_at = Instant::now();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let drawn_at = Instant::now();
|
||||
drop(widget);
|
||||
|
||||
assert!(replaced, "reuse must not silently time a fallback/refusal");
|
||||
check_text(&editor, after, cursor);
|
||||
check_draw(&renderer, cursor, NEW);
|
||||
renderer.drawn = None;
|
||||
for (phase, duration) in arm.phases().into_iter().zip([
|
||||
replaced_at.duration_since(started),
|
||||
laid_out_at.duration_since(replaced_at),
|
||||
drawn_at.duration_since(laid_out_at),
|
||||
drawn_at.duration_since(started),
|
||||
]) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
|
||||
"trial": trial, "warmup": trial == 0,
|
||||
"elapsed_ns": duration.as_nanos().to_string(),
|
||||
"elapsed_ms": duration.as_secs_f64() * 1000.0,
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
samples.0.entry(phase).or_default().push(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
|
||||
let fixture = Fixture::load();
|
||||
// Resolve the exact production approval and verify full Project/source once,
|
||||
// outside all intervals. The actual App Confirm evaluator remains separate.
|
||||
let cursor = {
|
||||
let app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
|
||||
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
|
||||
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
|
||||
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
|
||||
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
|
||||
})
|
||||
);
|
||||
let mut samples = Samples::default();
|
||||
for index in 0..=5 {
|
||||
let arms = if index % 2 == 0 {
|
||||
[Arm::Constructor, Arm::Reuse]
|
||||
} else {
|
||||
[Arm::Reuse, Arm::Constructor]
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
|
||||
);
|
||||
for arm in arms {
|
||||
trial(&fixture, cursor, arm, index, &mut samples);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
//! Manual timing through the real Document and App paths. No disk writes,
|
||||
//! executor polling, layout, GPU work or first-paint measurement occurs here.
|
||||
//! Register as a test child of app::source_rename to use its actual worker helpers.
|
||||
use super::*;
|
||||
use crate::source_workspace::Workspace;
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[path = "source_rename_layout_timing_tests.rs"]
|
||||
mod layout_timing_tests;
|
||||
|
||||
const OLD: &str = "oa_restore_work";
|
||||
const NEW: &str = "oa_restore_pressure";
|
||||
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
|
||||
const ORIGIN: &str = "traceability.arcadia";
|
||||
|
||||
struct Fixture {
|
||||
bundle: SourceBundle,
|
||||
project: Project,
|
||||
expected_bundle: SourceBundle,
|
||||
expected_project: Project,
|
||||
target: Id,
|
||||
cursor: text_editor::Cursor,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn load() -> Self {
|
||||
// The optional entry selects a parent-pinned copy of this exact fixture,
|
||||
// not an arbitrary performance generator or a mutable GUI destination.
|
||||
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/release1/project.arcadia")
|
||||
});
|
||||
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
|
||||
let bundle = loaded.reachable_bundle();
|
||||
assert_eq!(
|
||||
bundle.files.len(),
|
||||
30,
|
||||
"use the preserved release1 walkthrough"
|
||||
);
|
||||
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no stabilization/migration"
|
||||
);
|
||||
assert!(!document.dirty());
|
||||
let project = document.project().clone();
|
||||
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
|
||||
assert_eq!(project.elements[&target].alias, OLD);
|
||||
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
|
||||
let mut expected_project = project.clone();
|
||||
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
|
||||
|
||||
// Independent literal source oracle, authored before running the rename.
|
||||
// Do not replace the alias substring in filenames or the trace's alias.
|
||||
let mut expected_bundle = bundle.clone();
|
||||
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
|
||||
assert_eq!(
|
||||
declarations.matches("oa_activity oa_restore_work ").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
|
||||
*declarations = declarations
|
||||
.replace(
|
||||
"oa_activity oa_restore_work ",
|
||||
"oa_activity oa_restore_pressure ",
|
||||
)
|
||||
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
|
||||
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
|
||||
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
|
||||
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
|
||||
let source = &bundle.files[ORIGIN];
|
||||
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
|
||||
let position = |offset| {
|
||||
let prefix = &source[..offset];
|
||||
text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix
|
||||
.rsplit_once('\n')
|
||||
.map_or(prefix.len(), |(_, tail)| tail.len()),
|
||||
}
|
||||
};
|
||||
let cursor = text_editor::Cursor {
|
||||
position: position(start),
|
||||
selection: Some(position(start + OLD.len())),
|
||||
};
|
||||
assert!(nav::exact_cursor(source, cursor));
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
|
||||
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
|
||||
"identities": project.identities().len(), "active_file": ORIGIN,
|
||||
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
|
||||
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
|
||||
})
|
||||
);
|
||||
Self {
|
||||
bundle,
|
||||
project,
|
||||
expected_bundle,
|
||||
expected_project,
|
||||
target,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn app(&self) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = ORIGIN.into();
|
||||
app.selected = app.project().id;
|
||||
app.page = Page::Text;
|
||||
app.sync();
|
||||
app.source_editor.move_to(self.cursor);
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.rename_admission().is_ok());
|
||||
app
|
||||
}
|
||||
|
||||
fn check_candidate(&self, prepared: &PreparedAliasRename) {
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.before().bundle, self.bundle);
|
||||
assert_eq!(prepared.before().decoded.project, self.project);
|
||||
assert_eq!(prepared.after().bundle, self.expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, self.expected_project);
|
||||
}
|
||||
|
||||
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
|
||||
assert!(
|
||||
std::ptr::eq(app.document.compiled(), candidate_address),
|
||||
"adopt exact candidate allocation"
|
||||
);
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
|
||||
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert!(
|
||||
!app.document.dirty(),
|
||||
"live known saved baseline remains authoritative"
|
||||
);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"exactly one preceding model transaction"
|
||||
);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
|
||||
|
||||
impl Samples {
|
||||
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
|
||||
let started = Instant::now();
|
||||
let result = action();
|
||||
let elapsed = started.elapsed();
|
||||
// Printing, assertions and dropping the returned value are outside this
|
||||
// interval. Full Confirm still includes production's own local drops.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "timing", "phase": phase, "trial": trial,
|
||||
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
|
||||
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
self.0.entry(phase).or_default().push(elapsed);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn report(mut self) {
|
||||
for (phase, values) in &mut self.0 {
|
||||
assert_eq!(values.len(), 5);
|
||||
values.sort();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "summary", "phase": phase, "samples": values.len(),
|
||||
"min_ms": values[0].as_secs_f64() * 1000.0,
|
||||
"median_ms": values[2].as_secs_f64() * 1000.0,
|
||||
"max_ms": values[4].as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(app: &App, context: &Context) -> Target {
|
||||
let snapshot = app
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap();
|
||||
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
|
||||
resolve_target(&snapshot, context, offset).unwrap()
|
||||
}
|
||||
|
||||
fn active_job(app: &App) -> Job {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let id = form.pending.unwrap();
|
||||
assert_eq!(app.source_rename.in_flight, Some(id));
|
||||
Job {
|
||||
id,
|
||||
invocation: form.invocation,
|
||||
generation: form.generation,
|
||||
context: form.context.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
assert_eq!(target.id, fixture.target);
|
||||
let request = samples
|
||||
.time("document_request_mint", trial, || {
|
||||
app.document.alias_rename_request(target.id, NEW.into())
|
||||
})
|
||||
.unwrap();
|
||||
let prepared = samples
|
||||
.time("worker_document_prepare", trial, || request.prepare())
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = samples
|
||||
.time("worker_preview_and_origin", trial, || {
|
||||
prepare_approval(prepared, &target, ORIGIN)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(approval.preview.files().len(), 2);
|
||||
assert_eq!(approval.preview.changed_line_count(), 6);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
// Same-destination save completion is a legitimate live update after Preview.
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let changed = samples
|
||||
.time("document_guarded_commit", trial, || {
|
||||
app.document.commit_alias_rename(&approval.prepared)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(changed);
|
||||
samples.time("app_sync", trial, || app.sync());
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let begin = app.source_rename_message();
|
||||
// These Tasks are deliberately not polled: deterministic worker helpers
|
||||
// supply the same results through the real readiness messages below.
|
||||
drop(app.update(begin));
|
||||
let target_job = active_job(&app);
|
||||
let target = Arc::new(resolve(&app, &target_job.context));
|
||||
assert_eq!(target.id, fixture.target);
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: target_job,
|
||||
payload: Payload::Target(Ok(target.clone())),
|
||||
})));
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
|
||||
let generation = app.source_rename.form.as_ref().unwrap().generation;
|
||||
drop(app.update(Message::SourceRename(Event::Preview(
|
||||
invocation, generation,
|
||||
))));
|
||||
let preview_job = active_job(&app);
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
|
||||
drop(target);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
let expected_cursor = approval.cursor;
|
||||
let approval_id = approval.id;
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: preview_job,
|
||||
payload: Payload::Preview(Ok(approval)),
|
||||
})));
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
approval_id
|
||||
);
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
|
||||
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
|
||||
samples.time("confirm_returned_task_drop_without_polling", trial, || {
|
||||
drop(task)
|
||||
});
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert!(app.source_rename_undo_message().is_some());
|
||||
assert_eq!(app.source_editor.cursor(), expected_cursor);
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
|
||||
let fixture = Fixture::load();
|
||||
let mut samples = Samples::default();
|
||||
for trial in 0..=5 {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "record": "order", "trial": trial,
|
||||
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
|
||||
);
|
||||
if trial % 2 == 0 {
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
} else {
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
//! Current draft navigation, isolated from model application and disk discovery.
|
||||
use super::*;
|
||||
use crate::source_navigation::{self as nav, query};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn admission_notice(error: query::AdmissionError) -> String {
|
||||
use nav::symbols::{BuildError, InputProblem, Resource};
|
||||
let (limit, resource) = match error {
|
||||
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
|
||||
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
|
||||
language::OutlineResource::SourceBytes => Resource::SourceBytes,
|
||||
language::OutlineResource::Tokens => Resource::Tokens,
|
||||
language::OutlineResource::Declarations => Resource::Records,
|
||||
}),
|
||||
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
|
||||
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
|
||||
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
|
||||
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
|
||||
},
|
||||
};
|
||||
let unit = match resource {
|
||||
Resource::Files => "loaded files",
|
||||
Resource::SourceBytes => "source bytes",
|
||||
Resource::PathBytes => "bytes in file paths",
|
||||
Resource::Tokens => "source tokens",
|
||||
Resource::Records => "declarations and references",
|
||||
};
|
||||
format!(
|
||||
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
|
||||
)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn source_symbol_context(&self) -> nav::SymbolContext {
|
||||
nav::SymbolContext {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
|
||||
Message::SourceSymbols(self.source_symbol_context(), event)
|
||||
}
|
||||
|
||||
pub fn source_find_close_message(&self) -> Message {
|
||||
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
|
||||
}
|
||||
pub fn source_key(&self) -> query::SnapshotKey {
|
||||
query::SnapshotKey {
|
||||
workspace: self.source_workspace.session(),
|
||||
revision: self.source_workspace.revision(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
|
||||
let files: Vec<_> = self
|
||||
.source_workspace
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, buffer)| query::DraftFile {
|
||||
path,
|
||||
text: &buffer.text,
|
||||
})
|
||||
.collect();
|
||||
let accepted = self.document.bundle();
|
||||
let validated: Vec<_> = accepted
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, text)| query::DraftFile { path, text })
|
||||
.collect();
|
||||
use_input(query::Input {
|
||||
key: self.source_key(),
|
||||
entry: &self.source_workspace.entry,
|
||||
files: &files,
|
||||
applied: self.source_workspace.applied,
|
||||
validation: self
|
||||
.source_workspace
|
||||
.applied
|
||||
.then_some(query::ValidatedSources {
|
||||
entry: &accepted.entry,
|
||||
files: &validated,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
|
||||
snapshot.key() == self.source_key()
|
||||
&& self.with_source_input(|input| snapshot.matches(input))
|
||||
}
|
||||
|
||||
pub fn source_navigation_available(&self) -> bool {
|
||||
self.page == Page::Text
|
||||
&& !self.source_rename.open()
|
||||
&& !self.busy
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.workflow.is_none()
|
||||
&& !self.hosted.visible
|
||||
&& self.model_menu.is_none()
|
||||
&& self.text_workspace.menu().is_none()
|
||||
&& self.architecture.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
|
||||
/// Mounted widget IDs change when another interaction supersedes a reveal.
|
||||
/// A queued focus operation therefore cannot find a subsequently reused editor.
|
||||
pub fn source_editor_id(&self) -> iced::widget::Id {
|
||||
iced::widget::Id::from(format!(
|
||||
"source-editor-{}-{}-{}",
|
||||
self.source_workspace.session(),
|
||||
self.source_workspace.active,
|
||||
self.source_symbols.interaction
|
||||
))
|
||||
}
|
||||
|
||||
/// The reviewed diagram-query union is not integrated into this source yet.
|
||||
/// Its eventual adapter must return the peer's captured chooser message.
|
||||
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn source_reveal_ticket(&self) -> Option<Id> {
|
||||
self.source_symbols
|
||||
.reveal
|
||||
.as_ref()
|
||||
.map(|reveal| reveal.ticket)
|
||||
}
|
||||
|
||||
pub(super) fn source_interaction(&mut self) {
|
||||
self.source_symbols.interaction = Id::new_v4();
|
||||
self.source_symbols.request = None;
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_jump = None;
|
||||
}
|
||||
|
||||
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = select_line;
|
||||
self.source_symbols.reveal = Some(nav::Reveal {
|
||||
ticket: Id::new_v4(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
snapshot: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn restore_source_editor_focus(&mut self) {
|
||||
let cursor = self.source_editor.cursor();
|
||||
if self.source_navigation_available()
|
||||
&& nav::exact_cursor(self.source_workspace.text(), cursor)
|
||||
{
|
||||
self.queue_source_reveal(cursor, false);
|
||||
// This ticket restores focus to the live editor, not a saved cursor.
|
||||
// Iced's Cursor omits Word/Line selection kind; moving back to that
|
||||
// lossy value would collapse an actual word/line selection.
|
||||
self.source_jump = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
|
||||
let Some(reveal) = &self.source_symbols.reveal else {
|
||||
return Task::none();
|
||||
};
|
||||
if Some(reveal.ticket) != ticket
|
||||
|| reveal.interaction != self.source_symbols.interaction
|
||||
|| !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
reveal.raw_interaction,
|
||||
)
|
||||
|| reveal.snapshot != self.source_key()
|
||||
|| reveal.path != self.source_workspace.active
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let raw_interaction = reveal.raw_interaction;
|
||||
self.source_symbols.reveal = None;
|
||||
if let Some(cursor) = self.source_jump.take() {
|
||||
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
|
||||
return Task::none();
|
||||
}
|
||||
self.source_editor.move_to(cursor);
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
}
|
||||
// A valid ticket with no jump is focus-only. All intentional navigation
|
||||
// still takes the cursor branch above, including explicit line selection.
|
||||
nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
nav::SymbolEvent::Close => {
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
nav::SymbolEvent::Back => {
|
||||
self.source_symbol_back();
|
||||
// A successful Back needs no empty results panel. Retain an
|
||||
// already open panel, or reveal an actionable return notice.
|
||||
if self.source_symbols.notice.is_some() {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
let refs = self.text_workspace.references();
|
||||
return iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
reference,
|
||||
index,
|
||||
..
|
||||
} => {
|
||||
let Some(results) = self.source_symbols.results.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
if results.stamp != stamp
|
||||
|| stamp.snapshot != self.source_key()
|
||||
|| self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_none_or(|cache| !self.source_snapshot_matches(cache))
|
||||
{
|
||||
self.source_symbols.notice =
|
||||
Some("Source changed. Run navigation again for current locations.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let destination = if reference {
|
||||
results
|
||||
.references
|
||||
.get(index)
|
||||
.map(|row| (&row.path, row.position, row.end_position))
|
||||
} else {
|
||||
results
|
||||
.definitions
|
||||
.get(index)
|
||||
.map(|row| (&row.path, row.position, row.end_position))
|
||||
};
|
||||
if let Some((path, start, end)) = destination {
|
||||
self.source_symbol_jump(path, start, end);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
if !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
) {
|
||||
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_symbols.in_flight.is_some() {
|
||||
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let cursor = self.source_editor.cursor();
|
||||
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
|
||||
self.source_symbols.notice =
|
||||
Some("Place the cursor on an alias or its @ reference.".into());
|
||||
return Task::none();
|
||||
};
|
||||
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
|
||||
Some(serial) => serial,
|
||||
None => {
|
||||
self.reset_source_navigation_session();
|
||||
self.source_symbols.back.clear();
|
||||
self.source_symbols.cache = None;
|
||||
0
|
||||
}
|
||||
};
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: self.source_key(),
|
||||
serial: self.source_symbols.serial,
|
||||
},
|
||||
path: self.source_workspace.active.clone(),
|
||||
offset,
|
||||
cursor,
|
||||
action,
|
||||
};
|
||||
let cache = self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.filter(|cache| self.source_snapshot_matches(cache))
|
||||
.cloned();
|
||||
let input = if cache.is_none() {
|
||||
match self
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
{
|
||||
Ok(input) => Some(input),
|
||||
Err(error) => {
|
||||
self.source_symbols.notice = Some(admission_notice(error));
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.source_symbols.in_flight = Some(request.job);
|
||||
self.source_symbols.request = Some(request.clone());
|
||||
self.source_symbols.display_request = Some(request.clone());
|
||||
self.text_workspace
|
||||
.begin_references(request.stamp, request.path.clone());
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
let worker = Task::perform(
|
||||
async move {
|
||||
let worker_request = request.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let snapshot = match cache {
|
||||
Some(cache) => cache,
|
||||
None => Arc::new(
|
||||
input
|
||||
.expect("admitted input for uncached request")
|
||||
.capture()
|
||||
.map_err(admission_notice)?,
|
||||
),
|
||||
};
|
||||
let results = snapshot
|
||||
.query(query::Query {
|
||||
stamp: worker_request.stamp,
|
||||
path: &worker_request.path,
|
||||
byte_offset: worker_request.offset,
|
||||
action: worker_request.action,
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
|
||||
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
|
||||
})?;
|
||||
Ok((snapshot, Arc::new(results)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
|
||||
nav::Completion { request, result }
|
||||
},
|
||||
Message::SourceSymbolsReady,
|
||||
);
|
||||
Task::batch([
|
||||
worker,
|
||||
iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. },
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
|
||||
let request = completion.request;
|
||||
if self.source_symbols.in_flight != Some(request.job) {
|
||||
return Task::none();
|
||||
}
|
||||
self.source_symbols.in_flight = None;
|
||||
let wanted_navigation = self
|
||||
.source_symbols
|
||||
.request
|
||||
.take()
|
||||
.is_some_and(|current| current == request)
|
||||
&& request.interaction == self.source_symbols.interaction
|
||||
&& request.stamp.snapshot == self.source_key()
|
||||
&& nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
request.raw_interaction,
|
||||
)
|
||||
&& request.path == self.source_workspace.active
|
||||
&& request.cursor == self.source_editor.cursor()
|
||||
&& self.source_navigation_available();
|
||||
let wanted_display = self
|
||||
.source_symbols
|
||||
.display_request
|
||||
.as_ref()
|
||||
.is_some_and(|current| current == &request)
|
||||
&& request.stamp.snapshot == self.source_key();
|
||||
if !wanted_display {
|
||||
return Task::none();
|
||||
}
|
||||
match completion.result {
|
||||
Ok((snapshot, results))
|
||||
if self.source_snapshot_matches(&snapshot)
|
||||
&& results.stamp == request.stamp
|
||||
&& results.action == request.action =>
|
||||
{
|
||||
self.source_symbols.cache = Some(snapshot);
|
||||
self.source_symbols.results = Some(results.clone());
|
||||
self.text_workspace.install_reference_files(
|
||||
results.stamp,
|
||||
results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.clone())
|
||||
.chain(results.references.iter().map(|row| row.path.clone())),
|
||||
);
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& let Some(row) = results.definitions.first()
|
||||
{
|
||||
self.source_symbol_jump(&row.path, row.position, row.end_position);
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
|
||||
}
|
||||
Err(error) => self.source_symbols.notice = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
|
||||
let cursor = text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line: start.line,
|
||||
column: start.byte_column,
|
||||
},
|
||||
selection: Some(text_editor::Position {
|
||||
line: end.line,
|
||||
column: end.byte_column,
|
||||
}),
|
||||
};
|
||||
if self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(path)
|
||||
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
|
||||
{
|
||||
self.source_symbols.notice =
|
||||
Some("That source location is no longer available. Run navigation again.".into());
|
||||
return;
|
||||
}
|
||||
// The destination is admitted. Capture effective Word/Line ranges only
|
||||
// now, when this editor is actually about to leave its current position.
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if let Some(buffer) = self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&self.source_workspace.active)
|
||||
{
|
||||
let origin = nav::HistoryEntry {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
generation: buffer.generation(),
|
||||
cursor: departure,
|
||||
};
|
||||
self.source_symbols
|
||||
.back
|
||||
.retain(|entry| entry.session == origin.session);
|
||||
if self.source_symbols.back.len() == 64 {
|
||||
self.source_symbols.back.remove(0);
|
||||
}
|
||||
self.source_symbols.back.push(origin);
|
||||
}
|
||||
self.source_workspace.switch(path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
|
||||
fn source_symbol_back(&mut self) {
|
||||
while let Some(origin) = self.source_symbols.back.pop() {
|
||||
if origin.session != self.source_workspace.session() {
|
||||
continue;
|
||||
}
|
||||
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
|
||||
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
|
||||
return;
|
||||
};
|
||||
let unchanged = origin.generation == buffer.generation();
|
||||
let mut cursor = if unchanged {
|
||||
origin.cursor
|
||||
} else if origin.path == self.source_workspace.active {
|
||||
self.source_editor.cursor()
|
||||
} else {
|
||||
buffer.cursor
|
||||
};
|
||||
if !nav::exact_cursor(&buffer.text, cursor) {
|
||||
self.source_symbols.notice = Some(
|
||||
"The previous location changed. Open the file to choose a current position."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if !unchanged && origin.path == self.source_workspace.active {
|
||||
cursor = departure;
|
||||
}
|
||||
self.source_workspace.switch(&origin.path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
self.source_symbols.notice =
|
||||
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
|
||||
return;
|
||||
}
|
||||
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,646 +0,0 @@
|
||||
//! Text-only presentation and captured command envelopes. Source/model authority
|
||||
//! remains in the existing App controllers; dispatch this envelope before the
|
||||
//! generic source_interaction invalidation in App::update.
|
||||
use super::{App, Message};
|
||||
use crate::{context_menu, source_navigation as nav};
|
||||
use iced::{
|
||||
Point, Task,
|
||||
widget::{self, text_editor::Cursor},
|
||||
};
|
||||
use nav::query::{QueryStamp, SnapshotKey};
|
||||
use oa_core::{Id, source_project::SourceLocation};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrawerTab {
|
||||
References,
|
||||
Problems,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Find,
|
||||
GoToLine,
|
||||
Definition,
|
||||
References,
|
||||
RenameAlias,
|
||||
Back,
|
||||
UndoText,
|
||||
RedoText,
|
||||
UndoModel,
|
||||
RedoModel,
|
||||
UndoRename,
|
||||
RevertFile,
|
||||
ShowProblems,
|
||||
ShowStatus,
|
||||
ShowDiagramUsages,
|
||||
}
|
||||
impl Command {
|
||||
fn shortcut(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Find => Some("Ctrl F"),
|
||||
Self::GoToLine => Some("Ctrl G"),
|
||||
Self::Definition => Some("F12"),
|
||||
Self::References => Some("Shift F12"),
|
||||
Self::RenameAlias => Some("F2"),
|
||||
Self::Back => Some("Alt ←"),
|
||||
Self::UndoText => Some("Ctrl Z"),
|
||||
Self::RedoText => Some("Ctrl Y"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Find => "Find in file",
|
||||
Self::GoToLine => "Go to line",
|
||||
Self::Definition => "Go to definition",
|
||||
Self::References => "Find references",
|
||||
Self::RenameAlias => "Rename source alias",
|
||||
Self::Back => "Back in source",
|
||||
Self::UndoText => "Undo text edit",
|
||||
Self::RedoText => "Redo text edit",
|
||||
Self::UndoModel => "Undo model change",
|
||||
Self::RedoModel => "Redo model change",
|
||||
Self::UndoRename => "Undo source rename",
|
||||
Self::RevertFile => "Revert current file",
|
||||
Self::ShowProblems => "Source problems",
|
||||
Self::ShowStatus => "Source status",
|
||||
Self::ShowDiagramUsages => "Show diagram usages",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandContext {
|
||||
document: Id,
|
||||
document_revision: u64,
|
||||
source: SnapshotKey,
|
||||
path: String,
|
||||
cursor: Cursor,
|
||||
interaction: Id,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandRequest {
|
||||
context: CommandContext,
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuRequest {
|
||||
context: CommandContext,
|
||||
anchor: Point,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DiagnosticPublication {
|
||||
generation: Id,
|
||||
source: SnapshotKey,
|
||||
}
|
||||
impl DiagnosticPublication {
|
||||
pub fn generation(self) -> Id {
|
||||
self.generation
|
||||
}
|
||||
pub fn source(self) -> SnapshotKey {
|
||||
self.source
|
||||
}
|
||||
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
|
||||
DiagnosticTarget {
|
||||
publication: self,
|
||||
visit,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticTarget {
|
||||
publication: DiagnosticPublication,
|
||||
visit: Id,
|
||||
location: SourceLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ShowDrawer(DrawerTab),
|
||||
HideDrawer,
|
||||
ResizeDrawer(f32),
|
||||
ToggleFile {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
path: String,
|
||||
},
|
||||
ReferenceScrolled {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
y: f32,
|
||||
},
|
||||
Command(CommandRequest),
|
||||
OpenMenu(MenuRequest),
|
||||
DismissMenu(Id),
|
||||
Invoke {
|
||||
token: Id,
|
||||
command: Command,
|
||||
},
|
||||
Diagnostic(DiagnosticTarget),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FrozenEntry {
|
||||
command: Command,
|
||||
action: Option<Action>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
enum Action {
|
||||
Dispatch(Box<Message>),
|
||||
Drawer(DrawerTab),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
token: Id,
|
||||
popup_token: u64,
|
||||
anchor: Point,
|
||||
context: CommandContext,
|
||||
entries: Vec<FrozenEntry>,
|
||||
}
|
||||
impl Menu {
|
||||
pub fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReferencePresentation {
|
||||
stamp: Option<QueryStamp>,
|
||||
origin: String,
|
||||
files: BTreeSet<String>,
|
||||
installed: bool,
|
||||
collapsed_files: BTreeSet<String>,
|
||||
scroll_y: f32,
|
||||
}
|
||||
impl Default for ReferencePresentation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stamp: None,
|
||||
origin: String::new(),
|
||||
files: BTreeSet::new(),
|
||||
installed: false,
|
||||
collapsed_files: BTreeSet::new(),
|
||||
scroll_y: 0.,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ReferencePresentation {
|
||||
pub fn stamp(&self) -> Option<QueryStamp> {
|
||||
self.stamp
|
||||
}
|
||||
pub fn scroll_y(&self) -> f32 {
|
||||
self.scroll_y
|
||||
}
|
||||
pub fn collapsed(&self, path: &str) -> bool {
|
||||
self.collapsed_files.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
drawer: Option<DrawerTab>,
|
||||
drawer_visit: Id,
|
||||
drawer_height: f32,
|
||||
references: ReferencePresentation,
|
||||
menu: Option<Menu>,
|
||||
diagnostic_publication: Option<DiagnosticPublication>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
drawer: None,
|
||||
drawer_visit: Id::new_v4(),
|
||||
drawer_height: 180.,
|
||||
references: ReferencePresentation::default(),
|
||||
menu: None,
|
||||
diagnostic_publication: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub fn drawer_visit(&self) -> Id {
|
||||
self.drawer_visit
|
||||
}
|
||||
pub fn reference_scroll_id(&self) -> widget::Id {
|
||||
widget::Id::from(format!("text-references-{}", self.drawer_visit))
|
||||
}
|
||||
pub fn drawer(&self) -> Option<DrawerTab> {
|
||||
self.drawer
|
||||
}
|
||||
pub fn drawer_height(&self) -> f32 {
|
||||
self.drawer_height
|
||||
}
|
||||
pub fn references(&self) -> &ReferencePresentation {
|
||||
&self.references
|
||||
}
|
||||
pub fn menu(&self) -> Option<&Menu> {
|
||||
self.menu.as_ref()
|
||||
}
|
||||
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
|
||||
self.diagnostic_publication
|
||||
}
|
||||
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
|
||||
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
|
||||
let publication = DiagnosticPublication {
|
||||
generation: Id::new_v4(),
|
||||
source,
|
||||
};
|
||||
self.diagnostic_publication = Some(publication);
|
||||
publication
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
pub fn dismiss_menu(&mut self) {
|
||||
self.menu = None;
|
||||
}
|
||||
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
|
||||
self.references = ReferencePresentation {
|
||||
stamp: Some(stamp),
|
||||
origin,
|
||||
..ReferencePresentation::default()
|
||||
};
|
||||
self.show_drawer(Some(DrawerTab::References));
|
||||
}
|
||||
/// Install presentation groups only for the current completed query. Repeated
|
||||
/// delivery of the same completion cannot undo the user's group choices.
|
||||
pub fn install_reference_files(
|
||||
&mut self,
|
||||
stamp: QueryStamp,
|
||||
paths: impl IntoIterator<Item = String>,
|
||||
) {
|
||||
let refs = &mut self.references;
|
||||
if refs.stamp != Some(stamp) || refs.installed {
|
||||
return;
|
||||
}
|
||||
refs.files = paths.into_iter().collect();
|
||||
refs.collapsed_files = refs.files.clone();
|
||||
let expanded = if refs.files.contains(&refs.origin) {
|
||||
Some(refs.origin.clone())
|
||||
} else {
|
||||
refs.files.first().cloned()
|
||||
};
|
||||
if let Some(path) = expanded {
|
||||
refs.collapsed_files.remove(&path);
|
||||
}
|
||||
refs.installed = true;
|
||||
}
|
||||
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
|
||||
self.drawer = drawer;
|
||||
self.drawer_visit = Id::new_v4();
|
||||
}
|
||||
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer != Some(DrawerTab::References)
|
||||
|| self.drawer_visit != visit
|
||||
|| refs.stamp != Some(stamp)
|
||||
|| !refs.files.contains(&path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !refs.collapsed_files.remove(&path) {
|
||||
refs.collapsed_files.insert(path);
|
||||
}
|
||||
// Collapsing a group can replace a pressed row at the same position.
|
||||
self.drawer_visit = Id::new_v4();
|
||||
true
|
||||
}
|
||||
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer == Some(DrawerTab::References)
|
||||
&& refs.stamp == Some(stamp)
|
||||
&& self.drawer_visit == visit
|
||||
&& y.is_finite()
|
||||
&& y >= 0.
|
||||
{
|
||||
refs.scroll_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn text_command_context(&self) -> CommandContext {
|
||||
CommandContext {
|
||||
document: self.canvas_document,
|
||||
document_revision: self.document.revision(),
|
||||
source: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
}
|
||||
}
|
||||
pub fn text_command_message(&self, command: Command) -> Message {
|
||||
Message::TextWorkspace(Event::Command(CommandRequest {
|
||||
context: self.text_command_context(),
|
||||
command,
|
||||
}))
|
||||
}
|
||||
pub fn text_menu_message(&self, anchor: Point) -> Message {
|
||||
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
|
||||
context: self.text_command_context(),
|
||||
anchor,
|
||||
}))
|
||||
}
|
||||
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
|
||||
let menu = self.text_workspace.menu.as_ref()?;
|
||||
Some(context_menu::Menu {
|
||||
token: menu.popup_token,
|
||||
anchor: menu.anchor,
|
||||
entries: menu
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
|
||||
rows.push(context_menu::Entry::separator());
|
||||
}
|
||||
rows.push(context_menu::Entry::Action {
|
||||
label: entry.command.label().into(),
|
||||
shortcut: entry.command.shortcut().map(str::to_owned),
|
||||
message: entry.action.as_ref().map(|_| {
|
||||
Message::TextWorkspace(Event::Invoke {
|
||||
token: menu.token(),
|
||||
command: entry.command,
|
||||
})
|
||||
}),
|
||||
});
|
||||
rows
|
||||
})
|
||||
.collect(),
|
||||
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
|
||||
})
|
||||
}
|
||||
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
(publication.source == self.source_key()
|
||||
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
|
||||
.then(|| {
|
||||
Message::TextWorkspace(Event::Diagnostic(
|
||||
publication.target(self.text_workspace.drawer_visit(), location),
|
||||
))
|
||||
})
|
||||
}
|
||||
pub(crate) fn source_annotation_snapshot(
|
||||
&self,
|
||||
) -> Option<crate::source_annotations::Snapshot<'_>> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
let buffer = self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&self.source_workspace.active)?;
|
||||
if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::source_annotations::Snapshot {
|
||||
publication: publication.generation,
|
||||
source: self.source_snapshot.clone(),
|
||||
path: &self.source_workspace.active,
|
||||
diagnostics: &self.source_diagnostics,
|
||||
})
|
||||
}
|
||||
fn text_command_action(&self, command: Command) -> Option<Action> {
|
||||
// Menu has been taken before dispatch-time admission. This intentionally
|
||||
// uses navigation admission, not rename admission, for Find/text history.
|
||||
if !self.source_navigation_available() {
|
||||
return None;
|
||||
}
|
||||
let message =
|
||||
match command {
|
||||
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
|
||||
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
|
||||
Command::Definition => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
|
||||
Command::References => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
|
||||
Command::RenameAlias => {
|
||||
self.rename_admission().ok()?;
|
||||
self.source_rename_message()
|
||||
}
|
||||
Command::Back => {
|
||||
if !self
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.any(|entry| entry.session == self.source_workspace.session())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.source_symbol_message(nav::SymbolEvent::Back)
|
||||
}
|
||||
Command::UndoText => Message::SourceUndo,
|
||||
Command::RedoText => Message::SourceRedo,
|
||||
Command::UndoModel | Command::RedoModel => {
|
||||
if self.source_dirty() || self.draft_dirty() {
|
||||
return None;
|
||||
}
|
||||
if command == Command::UndoModel {
|
||||
if !self.document.can_undo() {
|
||||
return None;
|
||||
}
|
||||
Message::Undo
|
||||
} else {
|
||||
if !self.document.can_redo() {
|
||||
return None;
|
||||
}
|
||||
Message::Redo
|
||||
}
|
||||
}
|
||||
Command::UndoRename => self.source_rename_undo_message()?,
|
||||
Command::RevertFile => Message::RevertSource,
|
||||
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
|
||||
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
|
||||
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
|
||||
};
|
||||
Some(Action::Dispatch(Box::new(message)))
|
||||
}
|
||||
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::OpenMenu(request) => {
|
||||
if request.context != self.text_command_context()
|
||||
|| !self.source_navigation_available()
|
||||
|| self.text_workspace.menu.is_some()
|
||||
|| !request.anchor.x.is_finite()
|
||||
|| !request.anchor.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.source_interaction();
|
||||
let commands = [
|
||||
Command::Find,
|
||||
Command::GoToLine,
|
||||
Command::Definition,
|
||||
Command::References,
|
||||
Command::RenameAlias,
|
||||
Command::Back,
|
||||
Command::UndoText,
|
||||
Command::RedoText,
|
||||
Command::UndoRename,
|
||||
Command::UndoModel,
|
||||
Command::RedoModel,
|
||||
Command::RevertFile,
|
||||
Command::ShowProblems,
|
||||
Command::ShowStatus,
|
||||
Command::ShowDiagramUsages,
|
||||
];
|
||||
let entries = commands
|
||||
.into_iter()
|
||||
.filter_map(|command| {
|
||||
let action = self.text_command_action(command);
|
||||
if command == Command::ShowDiagramUsages && action.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(FrozenEntry { command, action })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.menu_sequence = popup_token;
|
||||
self.text_workspace.menu = Some(Menu {
|
||||
token: Id::new_v4(),
|
||||
popup_token,
|
||||
anchor: request.anchor,
|
||||
context: self.text_command_context(),
|
||||
entries,
|
||||
});
|
||||
}
|
||||
Event::DismissMenu(token) => {
|
||||
if self
|
||||
.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.token == token)
|
||||
{
|
||||
self.text_workspace.menu = None;
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::Invoke { token, command } => {
|
||||
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
|
||||
menu.token == token && menu.context == self.text_command_context()
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
// Validate before closing; consume this invocation only once.
|
||||
// Never resolve a frozen row against a newly generated menu.
|
||||
let menu = self.text_workspace.menu.take().unwrap();
|
||||
let Some(action) = menu
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.and_then(|entry| entry.action)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.text_command_action(command).is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
Event::Command(request) => {
|
||||
if request.context != self.text_command_context() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(action) = self.text_command_action(request.command) {
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
}
|
||||
Event::ShowDrawer(tab) => {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(Some(tab));
|
||||
}
|
||||
Event::HideDrawer => {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
Event::ResizeDrawer(height) => {
|
||||
if self.source_navigation_available() && height.is_finite() {
|
||||
// The view applies the measured editor-allocation bound.
|
||||
let height = height.clamp(180., 1200.);
|
||||
if height != self.text_workspace.drawer_height {
|
||||
self.source_interaction();
|
||||
self.text_workspace.drawer_height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToggleFile { stamp, visit, path } => {
|
||||
if self.source_navigation_available()
|
||||
&& self.text_workspace.toggle_file(stamp, visit, path)
|
||||
{
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::ReferenceScrolled { stamp, visit, y } => {
|
||||
// Passive geometry must neither invalidate pending navigation
|
||||
// nor reset the editor identity, even while commands are blocked.
|
||||
self.text_workspace.reference_scrolled(stamp, visit, y);
|
||||
}
|
||||
Event::Diagnostic(target) => {
|
||||
if !self.source_navigation_available()
|
||||
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|
||||
|| self.text_workspace.drawer_visit() != target.visit
|
||||
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|
||||
|| target.publication.source != self.source_key()
|
||||
|| !self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&target.location.path)
|
||||
.is_some_and(|file| valid_span(&file.text, &target.location))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.source_interaction();
|
||||
self.jump_to_source(target.location);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
|
||||
match action {
|
||||
Action::Dispatch(message) => self.update(*message),
|
||||
Action::Drawer(tab) => self.show_text_drawer(Some(tab)),
|
||||
}
|
||||
}
|
||||
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
|
||||
self.source_interaction();
|
||||
self.text_workspace.show_drawer(drawer);
|
||||
if drawer == Some(DrawerTab::References) {
|
||||
let refs = self.text_workspace.references();
|
||||
widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_span(text: &str, location: &SourceLocation) -> bool {
|
||||
let span = location.span;
|
||||
span.start <= span.end
|
||||
&& span.end <= text.len()
|
||||
&& text.is_char_boundary(span.start)
|
||||
&& text.is_char_boundary(span.end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod focus_selection_tests;
|
||||
@@ -1,480 +0,0 @@
|
||||
//! The mounted CPU renderer and App use the same cosmic-text editor engine, but
|
||||
//! Iced's Content is parameterized by renderer and cannot be shared between them.
|
||||
//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret
|
||||
//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions.
|
||||
//! App's own Content is never replaced or cursor-normalized after selection. This
|
||||
//! tests the actual controller/content boundary without a window or GPU; native
|
||||
//! replay remains the check of the complete production renderer/layout path.
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, Shell, layout, widget as core_widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::keyboard::{self, Key, Modifiers};
|
||||
use iced::widget::{button, container, row, text_editor, text_input};
|
||||
use iced::{Element, Event, Font, Size, mouse};
|
||||
use oa_core::{Project, source_project::SourceBundle};
|
||||
use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering};
|
||||
|
||||
const SOURCE: &str = concat!(
|
||||
"// oa_contact tail\r\n",
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n",
|
||||
);
|
||||
const HOST: Size = Size::new(620., 260.);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
|
||||
fn ui<'a>(
|
||||
app: &App,
|
||||
content: &'a text_editor::Content<FontLayout>,
|
||||
) -> Element<'a, Message, iced::Theme, FontLayout> {
|
||||
let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open {
|
||||
row![
|
||||
text_input("Find", "")
|
||||
.id("source-find")
|
||||
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
|
||||
container(button("Close").on_press(app.source_find_close_message()))
|
||||
.id("selection-find-close"),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().into()
|
||||
};
|
||||
// Column::push drops zero-height children. Keep both paths mounted even
|
||||
// while Find is hidden, as the production retained-slot layout does.
|
||||
widget::Column::from_vec(vec![
|
||||
container(find)
|
||||
.height(if app.source_navigation.open { 32. } else { 0. })
|
||||
.into(),
|
||||
widget::text_editor(content)
|
||||
.id(app.source_editor_id())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(iced::Fill)
|
||||
.on_action(Message::SourceEdit)
|
||||
.into(),
|
||||
])
|
||||
.width(iced::Fill)
|
||||
.height(iced::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
content: text_editor::Content<FontLayout>,
|
||||
tree: core_widget::Tree,
|
||||
}
|
||||
|
||||
// Operations follow widget-provided traversal instead of assuming a fixed
|
||||
// relationship between layout nodes, transparent containers and state trees.
|
||||
struct Inspection {
|
||||
editor: core_widget::Id,
|
||||
focused: Option<bool>,
|
||||
close_bounds: Option<iced::Rectangle>,
|
||||
}
|
||||
|
||||
impl core_widget::Operation for Inspection {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
|
||||
operate(self);
|
||||
}
|
||||
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&core_widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
state: &mut dyn core_widget::operation::Focusable,
|
||||
) {
|
||||
if id == Some(&self.editor) {
|
||||
assert!(
|
||||
self.focused.replace(state.is_focused()).is_none(),
|
||||
"duplicate editor ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) {
|
||||
if id == Some(&core_widget::Id::new("selection-find-close")) {
|
||||
assert!(
|
||||
self.close_bounds.replace(bounds).is_none(),
|
||||
"duplicate Close target ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mounted {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
content: text_editor::Content::with_text(SOURCE),
|
||||
tree: core_widget::Tree::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec<Message> {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
for (event, cursor) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
*cursor,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::with_size(HOST),
|
||||
);
|
||||
if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) {
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"mounted input must be handled: {event:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn operation(&mut self, app: &App, mut op: Box<dyn core_widget::Operation>) {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
loop {
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
op.as_mut(),
|
||||
);
|
||||
match op.finish() {
|
||||
core_widget::operation::Outcome::Chain(next) => op = next,
|
||||
core_widget::operation::Outcome::None => break,
|
||||
core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn task(&mut self, app: &App, task: Task<Message>) {
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(op) = action else {
|
||||
panic!("expected a widget operation")
|
||||
};
|
||||
self.operation(app, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect(&mut self, app: &App) -> Inspection {
|
||||
let mut inspection = Inspection {
|
||||
editor: app.source_editor_id(),
|
||||
focused: None,
|
||||
close_bounds: None,
|
||||
};
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
&mut inspection,
|
||||
);
|
||||
inspection
|
||||
}
|
||||
|
||||
fn editor_focused(&mut self, app: &App) -> bool {
|
||||
self.inspect(app)
|
||||
.focused
|
||||
.expect("exactly one editor with the requested ID")
|
||||
}
|
||||
|
||||
fn close_message(&mut self, app: &App, escape: bool) -> Message {
|
||||
if escape {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
self.events(app, &[(event, mouse::Cursor::Unavailable)])
|
||||
.is_empty()
|
||||
);
|
||||
// The existing subscription tests cover the captured-Escape bridge.
|
||||
// Deliver its App message after this actual input captured the key.
|
||||
Message::Escape
|
||||
} else {
|
||||
let center = self
|
||||
.inspect(app)
|
||||
.close_bounds
|
||||
.expect("the mounted Find Close target exists")
|
||||
.center();
|
||||
let cursor = mouse::Cursor::Available(center);
|
||||
let mut messages = self.events(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let close = messages.remove(0);
|
||||
assert!(matches!(close, Message::SourceFindClose(..)));
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, app: &mut App, line: bool) {
|
||||
// One mounted update batch avoids delays between Iced's consecutive-click
|
||||
// timestamps; content actions are applied in their emitted order below.
|
||||
let at = mouse::Cursor::Available(Point::new(65., 25.));
|
||||
let events = (0..if line { 3 } else { 2 })
|
||||
.flat_map(|_| {
|
||||
[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let messages = self.events(app, &events);
|
||||
assert_eq!(messages.len(), if line { 3 } else { 2 });
|
||||
for (index, message) in messages.into_iter().enumerate() {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("editor action")
|
||||
};
|
||||
if index == 0 {
|
||||
assert!(matches!(action, text_editor::Action::Click(_)));
|
||||
self.content.perform(action);
|
||||
let caret = self.content.cursor();
|
||||
assert_eq!(caret.position.line, 0);
|
||||
assert!(
|
||||
(4..13).contains(&caret.position.column),
|
||||
"click must hit inside oa_contact: {caret:?}"
|
||||
);
|
||||
// App's GPU-typed Content has no public layout API. Copy only the
|
||||
// initial hit-tested caret before any Word/Line selection exists.
|
||||
app.source_editor.move_to(caret);
|
||||
} else {
|
||||
assert_eq!(
|
||||
action,
|
||||
if index == 1 {
|
||||
text_editor::Action::SelectWord
|
||||
} else {
|
||||
text_editor::Action::SelectLine
|
||||
}
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
self.content.selection().as_deref(),
|
||||
Some(if line {
|
||||
"// oa_contact tail"
|
||||
} else {
|
||||
"oa_contact"
|
||||
})
|
||||
);
|
||||
assert_eq!(app.source_editor.selection(), self.content.selection());
|
||||
assert!(self.editor_focused(app));
|
||||
}
|
||||
|
||||
fn type_replacement(&mut self, app: &mut App) {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Character("x".into()),
|
||||
modified_key: Key::Character("x".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: Some("x".into()),
|
||||
repeat: false,
|
||||
});
|
||||
let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else {
|
||||
panic!("typed edit")
|
||||
};
|
||||
assert_eq!(
|
||||
action,
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('x'))
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() {
|
||||
for line in [false, true] {
|
||||
for escape in [false, true] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection();
|
||||
let cursor = app.source_editor.cursor();
|
||||
// Word/Line selection has coincident raw endpoints. Merely checking
|
||||
// cursor equality after a move_to roundtrip cannot detect the defect.
|
||||
assert_eq!(cursor.selection, Some(cursor.position));
|
||||
let task = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
let close = mounted.close_message(&app, escape);
|
||||
let _ = app.update(close);
|
||||
let ticket = app
|
||||
.source_reveal_ticket()
|
||||
.expect("guarded restoration ticket");
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection(),
|
||||
selected,
|
||||
"selected bytes must survive focus restoration"
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), expected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() {
|
||||
for line in [false, true] {
|
||||
for old_roundtrip in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
let interior = app.source_editor.cursor();
|
||||
if old_roundtrip {
|
||||
app.source_editor.move_to(interior);
|
||||
assert_ne!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
}
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = if old_roundtrip {
|
||||
let mut text = SOURCE.to_owned();
|
||||
text.insert(interior.position.column, 'x');
|
||||
text
|
||||
} else {
|
||||
SOURCE.replacen(&selected, "x", 1)
|
||||
};
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() {
|
||||
for later_raw_input in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let selected = app.source_editor.selection();
|
||||
let open = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, open).await;
|
||||
let close = app.source_find_close_message();
|
||||
let _ = app.update(close);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let focus = if later_raw_input {
|
||||
app.update(Message::SourceViewport(Some(ticket)))
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, reopen).await;
|
||||
if later_raw_input {
|
||||
app.source_symbols
|
||||
.raw_interaction
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
mounted.task(&app, focus).await;
|
||||
} else {
|
||||
let stale = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, stale).await;
|
||||
}
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let target = text_editor::Cursor {
|
||||
position: text_editor::Position { line: 1, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
// Clear the previous semantic selection as actual show_source_file does when
|
||||
// moving to another source buffer, then exercise intentional navigation.
|
||||
app.show_source_file(false);
|
||||
app.queue_source_reveal(target, true);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert_eq!(app.source_editor.cursor().position, target.position);
|
||||
assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;"));
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
|
||||
//! Returned App worker tasks are not polled; completions use the normal captured
|
||||
//! query input and are delivered deterministically through App::update.
|
||||
use super::*;
|
||||
use crate::app::{Page, read_snapshot, write_snapshot};
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use oa_core::{
|
||||
Project,
|
||||
language::{Diagnostic, Span},
|
||||
source_project::{SourceBundle, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
// with_project uses in-memory Home metadata and no recovery directory.
|
||||
assert!(app.recovery.directory.is_none());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn save(app: &mut App, path: &Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
send(app, Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
fn reveal(app: &mut App) {
|
||||
if let Some(ticket) = app.source_reveal_ticket() {
|
||||
send(app, Message::SourceViewport(Some(ticket)));
|
||||
}
|
||||
}
|
||||
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
|
||||
send(app, message);
|
||||
let request = app.source_symbols.request.clone().expect("admitted query");
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
let snapshot = Arc::new(app.with_source_input(|input| {
|
||||
nav::query::admit_owned(input, nav::query::Limits::default())
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap()
|
||||
}));
|
||||
let results = Arc::new(
|
||||
snapshot
|
||||
.query(nav::query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, results)),
|
||||
}
|
||||
}
|
||||
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
|
||||
let text = app.source_workspace.text();
|
||||
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
|
||||
let prefix = &text[..start];
|
||||
let position = widget::text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix.rsplit_once('\n').unwrap().1.len(),
|
||||
};
|
||||
app.source_editor.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
let ready = query(app, nav::query::Action::Definition);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
reveal(app);
|
||||
assert_eq!(app.source_symbols.back.len(), 1);
|
||||
let ready = query(app, nav::query::Action::References);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
|
||||
assert!(app.source_symbols.display_request.is_some());
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
if pending {
|
||||
Some(query(app, nav::query::Action::Definition))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn warning(app: &App) -> SourceDiagnostic {
|
||||
let source = app.source_workspace.text();
|
||||
let primary = source.find("model").unwrap();
|
||||
let related = source.find("assess").unwrap();
|
||||
SourceDiagnostic {
|
||||
path: app.source_workspace.active.clone(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "lifecycle_warning".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 2,
|
||||
column: 9,
|
||||
message: "Retained warning with a related source location".into(),
|
||||
span: Span {
|
||||
start: primary,
|
||||
end: primary + 5,
|
||||
},
|
||||
},
|
||||
related: vec![SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span {
|
||||
start: related,
|
||||
end: related + 6,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn warning_messages(app: &App) -> [Message; 2] {
|
||||
let warning = &app.source_diagnostics[0];
|
||||
[
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: warning.path.clone(),
|
||||
span: warning.diagnostic.span,
|
||||
})
|
||||
.unwrap(),
|
||||
app.text_diagnostic_message(warning.related[0].clone())
|
||||
.unwrap(),
|
||||
]
|
||||
}
|
||||
fn assert_cleared(app: &App, job: Option<Id>) {
|
||||
assert_eq!(app.source_symbols.in_flight, job);
|
||||
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.references().stamp().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
let ready = seed_navigation(&mut app, true).unwrap();
|
||||
let job = ready.request.job;
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let old = warning_messages(&app);
|
||||
let warnings = app.source_diagnostics.clone();
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let raw = app.source_workspace.bundle();
|
||||
save(&mut app, &second_path);
|
||||
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, source.workspace);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
|
||||
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
|
||||
assert_eq!(app.source_diagnostics, warnings);
|
||||
let current = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.expect("identity-only save retained current warnings");
|
||||
assert_eq!(current.source(), app.source_key());
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_cleared(&app, Some(job));
|
||||
for message in old {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
for message in warning_messages(&app) {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related warnings remain actionable"
|
||||
);
|
||||
reveal(&mut app);
|
||||
}
|
||||
let cursor = app.source_editor.cursor();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
|
||||
{
|
||||
for pending in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("broken.arcadia");
|
||||
let invalid = format!(
|
||||
"{} include \"missing.arcadia\";\r\n}}\r\n",
|
||||
VALID.strip_suffix("}\r\n").unwrap()
|
||||
);
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
let mut app = fixture();
|
||||
let ready = seed_navigation(&mut app, pending);
|
||||
let job = ready.as_ref().map(|ready| ready.request.job);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let old = warning_messages(&app);
|
||||
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let old_source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let open_menu = app.text_menu_message(Point::new(20., 20.));
|
||||
send(&mut app, open_menu);
|
||||
assert!(app.text_workspace.menu().is_some());
|
||||
let loaded = read_snapshot(path.clone()).unwrap();
|
||||
send(&mut app, Message::Loaded(Ok(Some(loaded))));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.path.as_deref(), Some(path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, old_source.workspace);
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_cleared(&app, job);
|
||||
assert!(
|
||||
app.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.severity == Severity::Error)
|
||||
);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(publication.source(), app.source_key());
|
||||
assert_ne!(publication.generation(), old_publication.generation());
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let issue = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.path == app.source_workspace.active
|
||||
&& issue.diagnostic.span.end <= invalid.len()
|
||||
})
|
||||
.unwrap();
|
||||
let current = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, current);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
reveal(&mut app);
|
||||
if let Some(ready) = ready {
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
|
||||
for failed in [false, true] {
|
||||
let mut app = fixture();
|
||||
seed_navigation(&mut app, false);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
let results = app.source_symbols.results.clone().unwrap();
|
||||
let cache = app.source_symbols.cache.clone().unwrap();
|
||||
let display = app.source_symbols.display_request.clone();
|
||||
let back = app
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let key = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let result = if failed {
|
||||
Err("Fixture read failed before installing a workspace".into())
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
send(&mut app, Message::Loaded(result));
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
|
||||
assert_eq!(app.source_diagnostics, diagnostics);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.cache.as_ref().unwrap(),
|
||||
&cache
|
||||
));
|
||||
assert_eq!(app.source_symbols.display_request, display);
|
||||
assert_eq!(
|
||||
app.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
back
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let old = warning_messages(&app);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let draft = format!("{VALID}// unsaved current draft\r\n");
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert_ne!(publication.source(), app.source_key());
|
||||
let accepted = app.document.snapshot();
|
||||
save(&mut app, &second_path);
|
||||
assert!(
|
||||
app.text_workspace.diagnostic_publication().is_none(),
|
||||
"identity-only reset cannot turn an old source span into a current warning"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
assert!(
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: app.source_diagnostics[0].path.clone(),
|
||||
span: app.source_diagnostics[0].diagnostic.span,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), draft);
|
||||
assert!(app.source_dirty());
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second_path).unwrap(),
|
||||
VALID,
|
||||
"receipt records accepted bytes while the newer draft remains unsaved"
|
||||
);
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use oa_core::{Project, language::Span};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
app.publish_source_diagnostics(diagnostics);
|
||||
assert!(app.source_navigation_available());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
// Exercise the real envelope path; worker/lint futures are deliberately not
|
||||
// polled here. The parent owns serialized compile/runtime verification.
|
||||
let _ = app.update(Message::TextWorkspace(event));
|
||||
}
|
||||
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
|
||||
Event::ToggleFile {
|
||||
stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> Id {
|
||||
let message = app.text_menu_message(Point::new(10., 20.));
|
||||
let _ = app.update(message);
|
||||
app.text_workspace.menu().unwrap().token()
|
||||
}
|
||||
fn invoke(app: &mut App, token: Id, command: Command) {
|
||||
send(app, Event::Invoke { token, command });
|
||||
}
|
||||
fn enabled(app: &App, command: Command) -> bool {
|
||||
app.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.is_some_and(|entry| entry.action.is_some())
|
||||
}
|
||||
fn back_entry(app: &App) -> nav::HistoryEntry {
|
||||
nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: app.source_workspace.active.clone(),
|
||||
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
|
||||
cursor: app.source_editor.cursor(),
|
||||
}
|
||||
}
|
||||
fn stamp(app: &App, serial: u64) -> QueryStamp {
|
||||
QueryStamp {
|
||||
snapshot: app.source_key(),
|
||||
serial,
|
||||
}
|
||||
}
|
||||
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
|
||||
SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span { start, end },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
|
||||
let mut app = fixture();
|
||||
let old_interaction = app.source_symbols.interaction;
|
||||
let token = open(&mut app);
|
||||
assert_ne!(old_interaction, app.source_symbols.interaction);
|
||||
assert_eq!(
|
||||
app.text_workspace.menu.as_ref().unwrap().context,
|
||||
app.text_command_context()
|
||||
);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
let instance = app.source_find_instance;
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert_eq!(
|
||||
app.source_find_instance, instance,
|
||||
"replay must not reopen Find"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(!enabled(&app, Command::Back));
|
||||
app.source_symbols.back.push(back_entry(&app));
|
||||
assert!(
|
||||
!enabled(&app, Command::Back),
|
||||
"frozen presentation must remain disabled"
|
||||
);
|
||||
invoke(&mut app, token, Command::Back);
|
||||
assert_eq!(
|
||||
app.source_symbols.back.len(),
|
||||
1,
|
||||
"a newly enabled action cannot be substituted"
|
||||
);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
app.busy = true;
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
let new = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
|
||||
for change in 0..6 {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
match change {
|
||||
0 => {
|
||||
let value = format!("{}\n// changed", app.source_workspace.text());
|
||||
app.source_workspace.edit(value, app.source_editor.cursor());
|
||||
}
|
||||
1 => {
|
||||
let mut cursor = app.source_editor.cursor();
|
||||
cursor.position.column += 1;
|
||||
app.source_editor.move_to(cursor);
|
||||
}
|
||||
2 => app.canvas_document = Id::new_v4(),
|
||||
3 => app.source_workspace.active = "other.arcadia".into(),
|
||||
4 => app.source_symbols.interaction = Id::new_v4(),
|
||||
_ => {
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// accepted revision");
|
||||
assert!(app.document.apply_bundle(bundle).unwrap());
|
||||
}
|
||||
}
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open, "stale context case {change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let pending_name = "Pending property draft";
|
||||
// Use the actual property input route before source edits coexist. Changing
|
||||
// Draft::value alone is overwritten by its authoritative raw input buffer.
|
||||
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
|
||||
let assert_pending_property = |app: &App| {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(draft.inputs["/name"].raw, pending_name);
|
||||
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap()["name"].as_str(),
|
||||
Some(pending_name)
|
||||
);
|
||||
};
|
||||
assert_pending_property(&app);
|
||||
let initial = app.source_workspace.text().to_owned();
|
||||
app.source_workspace
|
||||
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert!(app.source_dirty() && app.draft_dirty());
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
|
||||
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert_pending_property(&app);
|
||||
let token = open(&mut app);
|
||||
invoke(&mut app, token, Command::UndoText);
|
||||
assert_eq!(app.source_workspace.text(), initial);
|
||||
assert_pending_property(&app);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_toolbar_request_cannot_retarget_after_source_change() {
|
||||
let mut app = fixture();
|
||||
let request = app.text_command_message(Command::Find);
|
||||
app.source_workspace
|
||||
.edit("// changed".into(), app.source_editor.cursor());
|
||||
let _ = app.update(request);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
|
||||
let previous = app.text_workspace.diagnostic_publication().unwrap();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(previous.source(), current.source());
|
||||
assert_ne!(previous.generation(), current.generation());
|
||||
for message in [primary, related] {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
app.source_interaction();
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.source_workspace
|
||||
.edit("// newer".into(), app.source_editor.cursor());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
app.source_workspace
|
||||
.edit("a雨b".into(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let mut missing = location(&app, 0, 1);
|
||||
missing.path = "missing.arcadia".into();
|
||||
for target in [
|
||||
location(&app, 3, 2),
|
||||
location(&app, 0, 6),
|
||||
location(&app, 2, 4),
|
||||
location(&app, 1, 3),
|
||||
missing,
|
||||
] {
|
||||
let message = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
|
||||
let mut app = fixture();
|
||||
let first = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(first, "b/use.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
|
||||
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "a/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let old_scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 123.,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_symbols.interaction, interaction,
|
||||
"scroll is passive"
|
||||
);
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Status));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
|
||||
let second = stamp(&app, 2);
|
||||
app.text_workspace
|
||||
.begin_references(second, "absent.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["old.arcadia".into()]);
|
||||
app.text_workspace
|
||||
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "b/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
|
||||
let mut app = fixture();
|
||||
open(&mut app);
|
||||
app.text_workspace.reset();
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.diagnostic_publication().is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
send(&mut app, Event::ResizeDrawer(f32::NAN));
|
||||
assert_eq!(app.text_workspace.drawer_height(), 180.);
|
||||
send(&mut app, Event::ResizeDrawer(f32::MAX));
|
||||
assert_eq!(app.text_workspace.drawer_height(), 1200.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "uses.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["uses.arcadia".into()]);
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let event = toggle(&app, current, "not-a-group.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old = QueryStamp {
|
||||
serial: 0,
|
||||
..current
|
||||
};
|
||||
let event = toggle(&app, old, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
send(&mut app, Event::ResizeDrawer(f32::NAN));
|
||||
send(&mut app, Event::ResizeDrawer(180.));
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.request.is_some());
|
||||
let event = toggle(&app, current, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_ne!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(
|
||||
app.source_symbols.in_flight,
|
||||
Some(request.job),
|
||||
"intent does not release an occupied worker"
|
||||
);
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
send(&mut app, Event::ResizeDrawer(240.));
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
send(&mut app, Event::HideDrawer);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
let path = app.source_workspace.active.clone();
|
||||
app.text_workspace.begin_references(current, path.clone());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, [path.clone()]);
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed(&path));
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
|
||||
widget::text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert_ne!(current.snapshot, app.source_key());
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(!app.text_workspace.references().collapsed(&path));
|
||||
assert_eq!(app.source_workspace.active, path);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
let sequence = app.text_workspace_menu().unwrap().token;
|
||||
app.reset(Project::empty(), None);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
app.page = Page::Text;
|
||||
let fresh = open(&mut app);
|
||||
assert_ne!(old, fresh);
|
||||
assert!(app.text_workspace_menu().unwrap().token > sequence);
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
|
||||
for hide in [false, true] {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
|
||||
let old = targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| app.text_diagnostic_message(target).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
let event = if hide {
|
||||
Event::HideDrawer
|
||||
} else {
|
||||
Event::ShowDrawer(DrawerTab::References)
|
||||
};
|
||||
send(&mut app, event);
|
||||
for reopened in [false, true] {
|
||||
if reopened {
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
} else {
|
||||
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
|
||||
}
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert_eq!(
|
||||
app.text_workspace.diagnostic_publication(),
|
||||
publication,
|
||||
"same list and source isolate the visit guard from publication freshness"
|
||||
);
|
||||
let context = app.text_command_context();
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
for message in old.iter().cloned() {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.text_command_context(),
|
||||
context,
|
||||
"stale diagnostic must fail before interaction invalidation"
|
||||
);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
let fresh = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(fresh);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related targets remain usable"
|
||||
);
|
||||
app.source_interaction();
|
||||
}
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
|
||||
for replacement in 0..3 {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "a.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
|
||||
let old = toggle(&app, current, "a.arcadia".into());
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
match replacement {
|
||||
0 => {
|
||||
send(&mut app, Event::HideDrawer);
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::References));
|
||||
}
|
||||
1 => {
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::References));
|
||||
}
|
||||
_ => {
|
||||
let event = toggle(&app, current, "b.arcadia".into());
|
||||
send(&mut app, event);
|
||||
}
|
||||
}
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
assert_ne!(visit, old_visit);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: current,
|
||||
visit,
|
||||
y: 84.,
|
||||
},
|
||||
);
|
||||
let scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let groups =
|
||||
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
|
||||
let context = app.text_command_context();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
for _ in 0..2 {
|
||||
send(&mut app, old.clone());
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
|
||||
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
assert_eq!(
|
||||
["a.arcadia", "b.arcadia"]
|
||||
.map(|path| app.text_workspace.references().collapsed(path)),
|
||||
groups,
|
||||
);
|
||||
assert_eq!(app.text_command_context(), context);
|
||||
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
let fresh = toggle(&app, current, "a.arcadia".into());
|
||||
send(&mut app, fresh);
|
||||
assert_eq!(
|
||||
app.text_workspace.references().collapsed("a.arcadia"),
|
||||
!groups[0]
|
||||
);
|
||||
assert_ne!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_ne!(app.source_symbols.interaction, context.interaction);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_snapshot_requires_exact_current_bytes_and_publication() {
|
||||
let mut app = fixture();
|
||||
let original_model = app.document.snapshot();
|
||||
let original_source = app.source_snapshot.clone();
|
||||
let publication = app.source_annotation_snapshot().unwrap().publication;
|
||||
let initial = app.source_annotation_snapshot().unwrap();
|
||||
assert!(Arc::ptr_eq(&initial.source, &original_source));
|
||||
assert_eq!(initial.path, app.source_workspace.active);
|
||||
assert!(std::ptr::eq(
|
||||
initial.diagnostics,
|
||||
app.source_diagnostics.as_slice()
|
||||
));
|
||||
|
||||
// Republish identical diagnostics: old cached geometry must get a new identity.
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
assert_ne!(
|
||||
app.source_annotation_snapshot().unwrap().publication,
|
||||
publication
|
||||
);
|
||||
|
||||
app.source_workspace
|
||||
.edit("// newer draft\n".into(), app.source_editor.cursor());
|
||||
assert!(
|
||||
app.source_annotation_snapshot().is_none(),
|
||||
"old publication after a real draft edit"
|
||||
);
|
||||
// A current source key alone must not bind new ranges to the old displayed text.
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
assert!(
|
||||
app.source_annotation_snapshot().is_none(),
|
||||
"displayed snapshot still contains old bytes"
|
||||
);
|
||||
app.show_source_file(false);
|
||||
let current = app.source_annotation_snapshot().unwrap();
|
||||
assert_eq!(current.source.as_ref(), "// newer draft\n");
|
||||
assert!(current.diagnostics.is_empty());
|
||||
assert!(Arc::ptr_eq(&original_model, &app.document.snapshot()));
|
||||
app.text_workspace.reset();
|
||||
assert!(app.source_annotation_snapshot().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() {
|
||||
let mut project = Project::empty();
|
||||
let element = project
|
||||
.add_element(
|
||||
oa_core::Kind::Activity,
|
||||
oa_core::Layer::Oa,
|
||||
"Assess incident",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Text;
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let model = app.document.snapshot();
|
||||
let entry = app.source_workspace.entry.clone();
|
||||
let fragment = app.document.compiled().index.declarations[&element]
|
||||
.path
|
||||
.clone();
|
||||
assert_ne!(entry, fragment);
|
||||
for path in [&entry, &fragment, &entry] {
|
||||
let _ = app.update(Message::SourceFile(path.clone()));
|
||||
let current = app.source_annotation_snapshot().unwrap();
|
||||
assert_eq!(current.path, path);
|
||||
assert_eq!(
|
||||
current.source.as_ref(),
|
||||
app.source_workspace.files[path].text
|
||||
);
|
||||
assert!(Arc::ptr_eq(¤t.source, &app.source_snapshot));
|
||||
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,7 @@ use iced::{
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(test)]
|
||||
mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
pub(crate) mod scenario;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
pub(crate) mod state_edit;
|
||||
pub(crate) mod state_menu;
|
||||
@@ -33,12 +29,12 @@ mod scenario_scroll {
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget};
|
||||
use iced::{Length, Vector, overlay};
|
||||
|
||||
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
|
||||
pub(super) fn observe<'a>(
|
||||
context: scenario::Context,
|
||||
area: scenario::ScrollArea,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
Element::new(Observer {
|
||||
content: content.into(),
|
||||
context,
|
||||
@@ -735,41 +731,16 @@ mod scenario_scroll {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Creation(creation::Event),
|
||||
Topology(topology::Event),
|
||||
Scenario(scenario::Event),
|
||||
States(states::Event),
|
||||
Submit,
|
||||
}
|
||||
impl Event {
|
||||
pub fn is_source_back(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
)))
|
||||
)
|
||||
}
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(_))
|
||||
| Self::Scenario(scenario::Event::Source(_))
|
||||
| Self::Topology(topology::Event::Source(_))
|
||||
)
|
||||
}
|
||||
pub fn starts_form(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Open(..))
|
||||
| Self::Topology(topology::Event::Start(..))
|
||||
Self::Topology(topology::Event::Start(..))
|
||||
| Self::Scenario(scenario::Event::Start(..))
|
||||
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
|
||||
)
|
||||
@@ -778,7 +749,6 @@ impl Event {
|
||||
matches!(
|
||||
self,
|
||||
Self::Submit
|
||||
| Self::Creation(creation::Event::Submit)
|
||||
| Self::Topology(topology::Event::Submit)
|
||||
| Self::Scenario(scenario::Event::Submit)
|
||||
| Self::States(states::Event::Submit)
|
||||
@@ -787,7 +757,6 @@ impl Event {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub(crate) creation: creation::State,
|
||||
pub active_owner: Option<Id>,
|
||||
pub diagram: state_view::State,
|
||||
pub(crate) topology: topology::State,
|
||||
@@ -804,7 +773,6 @@ struct RecoveryDraft {
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
enum RecoveryKind {
|
||||
Creation,
|
||||
State,
|
||||
Scenario,
|
||||
Process,
|
||||
@@ -814,7 +782,6 @@ impl State {
|
||||
/// viewport geometry and document/revision tokens never cross a restart.
|
||||
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
let drafts = [
|
||||
(RecoveryKind::Creation, self.creation.recovery_snapshot()?),
|
||||
(RecoveryKind::State, self.states.recovery_snapshot()?),
|
||||
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
|
||||
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
|
||||
@@ -850,9 +817,6 @@ impl State {
|
||||
}
|
||||
let mut restored = Self::default();
|
||||
match saved.kind {
|
||||
RecoveryKind::Creation => {
|
||||
restored.creation = creation::State::from_recovery(saved.draft)?
|
||||
}
|
||||
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
|
||||
RecoveryKind::Scenario => {
|
||||
restored.scenario = scenario::State::from_recovery(saved.draft)?
|
||||
@@ -867,7 +831,7 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.creation.has_pending() || self.pending_owner().is_some()
|
||||
self.pending_owner().is_some()
|
||||
}
|
||||
|
||||
pub fn pending_owner(&self) -> Option<Id> {
|
||||
@@ -880,9 +844,7 @@ impl State {
|
||||
/// Prepare the active form without discarding inputs. The caller must execute
|
||||
/// this exact command and acknowledge only a successful document transaction.
|
||||
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
|
||||
if self.creation.has_pending() {
|
||||
self.creation.prepare_pending(p).map(Some)
|
||||
} else if self.topology.pending_owner().is_some() {
|
||||
if self.topology.pending_owner().is_some() {
|
||||
self.topology.prepare_pending(p).map(Some)
|
||||
} else if self.scenario.pending_owner().is_some() {
|
||||
self.scenario.prepare_pending(p).map(Some)
|
||||
@@ -895,20 +857,7 @@ impl State {
|
||||
|
||||
/// Clear the active form after its prepared command commits successfully.
|
||||
/// Rejected commands must leave the form available for repair and retry.
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.creation.source_active()
|
||||
|| self.scenario.source_active()
|
||||
|| self.topology.source_active()
|
||||
}
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
|
||||
}
|
||||
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.creation.acknowledge_commit();
|
||||
self.topology.acknowledge_commit();
|
||||
self.scenario.acknowledge_commit();
|
||||
self.states.acknowledge_commit();
|
||||
@@ -918,8 +867,7 @@ impl State {
|
||||
if self.has_pending()
|
||||
&& matches!(
|
||||
&event,
|
||||
Event::Creation(creation::Event::Open(..))
|
||||
| Event::Topology(topology::Event::Start(..))
|
||||
Event::Topology(topology::Event::Start(..))
|
||||
| Event::Scenario(scenario::Event::Start(..))
|
||||
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
|
||||
)
|
||||
@@ -937,7 +885,6 @@ impl State {
|
||||
return Ok(None);
|
||||
}
|
||||
match event {
|
||||
Event::Creation(event) => self.creation.update(p, event),
|
||||
Event::Topology(event) => self.topology.update(p, event),
|
||||
Event::Scenario(event) => self.scenario.update(p, event),
|
||||
Event::States(event) => self.states.update(p, event),
|
||||
@@ -951,43 +898,6 @@ impl State {
|
||||
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let p = app.project();
|
||||
if let Some(card) = app.behavior.creation.view(p) {
|
||||
return card.map(|event| Message::Behavior(Event::Creation(event)));
|
||||
}
|
||||
if let Some(owner) = app.behavior.pending_owner() {
|
||||
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
|
||||
let unavailable = p.layer_of(owner) != Some(app.layer)
|
||||
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|
||||
|| (app.behavior.topology.pending_owner().is_some()
|
||||
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
|
||||
if unavailable {
|
||||
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
|
||||
if let Some(name) = app
|
||||
.behavior
|
||||
.scenario
|
||||
.pending_name()
|
||||
.or_else(|| app.behavior.topology.pending_name())
|
||||
{
|
||||
out =
|
||||
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
|
||||
}
|
||||
if let Some(child) = app.behavior.scenario.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
|
||||
} else if let Some(child) = app.behavior.topology.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
|
||||
} else {
|
||||
let cancel = if app.behavior.scenario.pending_owner().is_some() {
|
||||
Event::Scenario(scenario::Event::Cancel)
|
||||
} else if app.behavior.topology.pending_owner().is_some() {
|
||||
Event::Topology(topology::Event::Cancel)
|
||||
} else {
|
||||
Event::States(states::Event::Cancel)
|
||||
};
|
||||
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
|
||||
}
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1006,40 +916,23 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
.cloned();
|
||||
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
|
||||
let Some(selected) = selected else {
|
||||
return out
|
||||
.push(label(
|
||||
"Start a named process, scenario or state machine, then model its behavior here.",
|
||||
))
|
||||
.push(action(
|
||||
"New",
|
||||
Message::Behavior(Event::Creation(creation::Event::Open(app.layer))),
|
||||
))
|
||||
.padding(24)
|
||||
.into();
|
||||
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
|
||||
};
|
||||
let e = &p.elements[&selected.id];
|
||||
let compact_canvas = matches!(
|
||||
e.kind(),
|
||||
Kind::Process | Kind::FunctionalChain | Kind::Scenario
|
||||
);
|
||||
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain);
|
||||
if e.kind() == Kind::StateMachine {
|
||||
out = column![text("States & modes").size(20)].spacing(8);
|
||||
} else if compact_canvas {
|
||||
// The process/scenario canvas owns its compact remaining-height layout.
|
||||
} else if compact_process {
|
||||
// The process canvas owns its compact header and remaining-height layout.
|
||||
out = column![].spacing(6);
|
||||
} else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
out = column![text("Processes & functional chains").size(20)].spacing(8);
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
pick_list(choices, Some(selected), Message::BehaviorOwner)
|
||||
.width(Fill)
|
||||
.text_size(13)
|
||||
.padding(if compact_canvas { 6 } else { 10 }),
|
||||
action(
|
||||
"New",
|
||||
Message::Behavior(Event::Creation(creation::Event::Open(app.layer)))
|
||||
)
|
||||
]
|
||||
.spacing(8),
|
||||
.padding(if compact_process { 6 } else { 10 }),
|
||||
);
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return topology::view(app, e.id, out);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,766 +0,0 @@
|
||||
//! Local behavior creation. A draft is not a model object: only its validated
|
||||
//! batch may cross Document::execute, and acknowledgement follows success.
|
||||
use iced::{
|
||||
Element as UiElement, Fill,
|
||||
widget::{button, checkbox, column, container, row, scrollable, text, text_input},
|
||||
};
|
||||
use oa_core::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
pub const NAME_INPUT: &str = "behavior-creation-name";
|
||||
|
||||
// A focused text input consumes Escape. Preserve the application's modal
|
||||
// priority by emitting its ordinary Escape message from this narrow bridge.
|
||||
pub fn subscription(active: bool) -> iced::Subscription<()> {
|
||||
if active {
|
||||
iced::event::listen_with(captured_escape)
|
||||
} else {
|
||||
iced::Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_escape(
|
||||
event: iced::Event,
|
||||
status: iced::event::Status,
|
||||
_: iced::window::Id,
|
||||
) -> Option<()> {
|
||||
(status == iced::event::Status::Captured
|
||||
&& matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Family {
|
||||
Sequence,
|
||||
Scenario,
|
||||
Machine,
|
||||
}
|
||||
impl Family {
|
||||
fn kind(self, layer: Layer) -> Kind {
|
||||
match (self, layer) {
|
||||
(Self::Sequence, Layer::Oa) => Kind::Process,
|
||||
(Self::Sequence, Layer::Sa) => Kind::FunctionalChain,
|
||||
(Self::Scenario, _) => Kind::Scenario,
|
||||
(Self::Machine, _) => Kind::StateMachine,
|
||||
}
|
||||
}
|
||||
fn title(self, layer: Layer) -> &'static str {
|
||||
match (self, layer) {
|
||||
(Self::Sequence, Layer::Oa) => "Operational process",
|
||||
(Self::Sequence, Layer::Sa) => "Functional chain",
|
||||
(Self::Scenario, _) => "Scenario",
|
||||
(Self::Machine, _) => "State machine",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Picker {
|
||||
Owner,
|
||||
Context,
|
||||
}
|
||||
impl Picker {
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Owner => 0,
|
||||
Self::Context => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Open(Layer),
|
||||
Choose(Family),
|
||||
Name(String),
|
||||
Owner(Id),
|
||||
Viewpoint(ScenarioViewpoint),
|
||||
Context(Option<Id>),
|
||||
ContextOptions(bool),
|
||||
FirstView(bool),
|
||||
Search(Picker, String),
|
||||
Page(Picker, usize),
|
||||
Cancel,
|
||||
Submit,
|
||||
}
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
query: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
|
||||
version: u32,
|
||||
document: Id,
|
||||
layer: Layer,
|
||||
family: Option<Family>,
|
||||
name: String,
|
||||
owner: Option<Id>,
|
||||
viewpoint: Option<ScenarioViewpoint>,
|
||||
context: Option<Id>,
|
||||
#[serde(default)]
|
||||
context_open: bool,
|
||||
first_view: bool,
|
||||
element: Id,
|
||||
diagram: Id,
|
||||
placement: Id,
|
||||
browsers: [Browser; 2],
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Created {
|
||||
pub behavior: Id,
|
||||
pub layer: Layer,
|
||||
pub diagram: Option<Id>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.draft.is_some()
|
||||
}
|
||||
pub fn pending_layer(&self) -> Option<Layer> {
|
||||
self.draft.as_ref().map(|d| d.layer)
|
||||
}
|
||||
/// Capture this before successful acknowledgement; never select these IDs
|
||||
/// on mere planning or a rejected source/model transaction.
|
||||
pub fn created(&self) -> Option<Created> {
|
||||
let d = self.draft.as_ref()?;
|
||||
d.family.map(|family| Created {
|
||||
behavior: d.element,
|
||||
layer: d.layer,
|
||||
diagram: (family == Family::Machine && d.first_view).then_some(d.diagram),
|
||||
})
|
||||
}
|
||||
/// Match before execution, then apply this target only after success.
|
||||
/// Identity alone is insufficient: changed bodies or trailing operations
|
||||
/// must not inherit the prepared creation's selection/opening contract.
|
||||
pub fn created_by(&self, p: &Project, command: &Command) -> Option<Created> {
|
||||
let target = self.created()?;
|
||||
(self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target)
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
||||
let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if draft.version != 1 {
|
||||
return Err("Unsupported behavior creation draft version.".into());
|
||||
}
|
||||
// Raw values and stale references remain editable; planning validates
|
||||
// against the current document rather than silently fixing the draft.
|
||||
Ok(Self { draft: Some(draft) })
|
||||
}
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
pub fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose New to start a behavior.")?
|
||||
.command(p)
|
||||
}
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.family != Some(Family::Machine) {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.document != p.id {
|
||||
return Err("This behavior draft belongs to another document.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Machine { layer: d.layer },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.owner = Some(id);
|
||||
let preview = next
|
||||
.source
|
||||
.preview(p, next.owner)?
|
||||
.ok_or("New owner preview is missing.")?;
|
||||
if !eligible(&preview, next.layer, Picker::Owner, id) {
|
||||
return Err("This participant cannot own the machine.".into());
|
||||
}
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Cancel => {
|
||||
self.draft = None;
|
||||
return Ok(None);
|
||||
}
|
||||
Event::Open(layer) => {
|
||||
if self.has_pending() {
|
||||
return Err("Create or cancel the pending behavior first.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
version: 1,
|
||||
document: p.id,
|
||||
layer,
|
||||
family: None,
|
||||
name: String::new(),
|
||||
owner: None,
|
||||
viewpoint: None,
|
||||
context: None,
|
||||
context_open: false,
|
||||
first_view: false,
|
||||
element: Id::new_v4(),
|
||||
diagram: Id::new_v4(),
|
||||
placement: Id::new_v4(),
|
||||
browsers: Default::default(),
|
||||
});
|
||||
return Ok(None);
|
||||
}
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
_ => {}
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Choose New to start a behavior.")?;
|
||||
if d.document != p.id {
|
||||
return Err("This draft belongs to another document. Cancel it to start here.".into());
|
||||
}
|
||||
match event {
|
||||
Event::Choose(family) => {
|
||||
if d.family.is_some_and(|old| old != family) {
|
||||
return Err(
|
||||
"Cancel this draft before choosing a different behavior family.".into(),
|
||||
);
|
||||
}
|
||||
d.family = Some(family);
|
||||
}
|
||||
Event::Name(name) => d.name = name,
|
||||
Event::Owner(id) if d.family == Some(Family::Machine) => {
|
||||
if !eligible(p, d.layer, Picker::Owner, id) {
|
||||
return Err("Choose an existing participant in this layer.".into());
|
||||
}
|
||||
d.owner = Some(id);
|
||||
}
|
||||
Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => {
|
||||
if !viewpoints(d.layer).contains(&viewpoint) {
|
||||
return Err("Choose a scenario viewpoint from this layer.".into());
|
||||
}
|
||||
d.viewpoint = Some(viewpoint);
|
||||
}
|
||||
Event::Context(id) if d.family == Some(Family::Scenario) => {
|
||||
if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) {
|
||||
return Err("Choose an existing same-layer scenario context.".into());
|
||||
}
|
||||
d.context = id;
|
||||
}
|
||||
Event::ContextOptions(open) if d.family == Some(Family::Scenario) => {
|
||||
d.context_open = open;
|
||||
}
|
||||
Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value,
|
||||
Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 },
|
||||
Event::Page(picker, page) => d.browsers[picker.index()].page = page,
|
||||
Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(),
|
||||
_ => return Err("That field does not apply to this behavior family.".into()),
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] {
|
||||
match layer {
|
||||
Layer::Oa => [
|
||||
ScenarioViewpoint::OperationalActivity,
|
||||
ScenarioViewpoint::ParticipantInteraction,
|
||||
],
|
||||
Layer::Sa => [
|
||||
ScenarioViewpoint::Functional,
|
||||
ScenarioViewpoint::BoundaryInterface,
|
||||
],
|
||||
}
|
||||
}
|
||||
fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str {
|
||||
match viewpoint {
|
||||
ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges",
|
||||
ScenarioViewpoint::ParticipantInteraction => "Participants · interactions",
|
||||
ScenarioViewpoint::Functional => "Functions · functional exchanges",
|
||||
ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges",
|
||||
ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint",
|
||||
}
|
||||
}
|
||||
fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool {
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.layer() == layer
|
||||
&& match picker {
|
||||
Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine),
|
||||
Picker::Context => {
|
||||
e.kind().is_sequence()
|
||||
|| e.kind().is_work()
|
||||
|| e.kind().is_performer()
|
||||
|| matches!(
|
||||
e.kind(),
|
||||
Kind::Capability
|
||||
| Kind::SystemCapability
|
||||
| Kind::OperationalMission
|
||||
| Kind::Mission
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut matches: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| eligible(p, layer, picker, e.id))
|
||||
.filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query))
|
||||
.collect();
|
||||
matches.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
matches
|
||||
}
|
||||
|
||||
fn reference_context(p: &Project, e: &Element) -> String {
|
||||
let mut names = Vec::new();
|
||||
let mut parent = e.parent;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = parent {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
let Some(ancestor) = p.elements.get(&id) else {
|
||||
break;
|
||||
};
|
||||
names.push(ancestor.name.as_str());
|
||||
parent = ancestor.parent;
|
||||
}
|
||||
names.reverse();
|
||||
let location = if names.is_empty() {
|
||||
format!("{} root", e.layer())
|
||||
} else {
|
||||
format!("{} / {}", e.layer(), names.join(" / "))
|
||||
};
|
||||
format!("{} · {} · {location}", e.kind(), e.alias)
|
||||
}
|
||||
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
||||
.unwrap_or_else(|| "Missing element — choose a replacement".into())
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Machine { layer: self.layer },
|
||||
self.owner,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.owner)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.owner, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.document != p.id {
|
||||
return Err("This draft belongs to another document.".into());
|
||||
}
|
||||
let family = self
|
||||
.family
|
||||
.ok_or("Choose the behavior you want to create.")?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("Enter the behavior's name before creating it.".into());
|
||||
}
|
||||
if [self.element, self.diagram, self.placement]
|
||||
.into_iter()
|
||||
.any(|id| p.contains(id))
|
||||
|| self.element == self.diagram
|
||||
|| self.element == self.placement
|
||||
|| self.diagram == self.placement
|
||||
{
|
||||
return Err(
|
||||
"A reserved creation identity is already in use. Cancel and start a fresh draft."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let kind = family.kind(self.layer);
|
||||
let parent = if family == Family::Machine {
|
||||
Some(
|
||||
self.owner
|
||||
.filter(|id| eligible(p, self.layer, Picker::Owner, *id))
|
||||
.ok_or("Choose the participant that owns this state machine.")?,
|
||||
)
|
||||
} else {
|
||||
if self.owner.is_some() {
|
||||
return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into());
|
||||
}
|
||||
None
|
||||
};
|
||||
if family != Family::Machine && self.first_view {
|
||||
return Err("A state view can only accompany a state machine.".into());
|
||||
}
|
||||
let mut body = ElementBody::new(kind, self.layer)
|
||||
.ok_or("This behavior is unavailable in this layer.")?;
|
||||
if family == Family::Scenario {
|
||||
let viewpoint = self
|
||||
.viewpoint
|
||||
.filter(|v| viewpoints(self.layer).contains(v))
|
||||
.ok_or("Choose the scenario's viewpoint explicitly.")?;
|
||||
if self
|
||||
.context
|
||||
.is_some_and(|id| !eligible(p, self.layer, Picker::Context, id))
|
||||
{
|
||||
return Err(
|
||||
"The selected scenario context is missing or no longer compatible.".into(),
|
||||
);
|
||||
}
|
||||
body = ElementBody::shared_in(
|
||||
self.layer,
|
||||
SharedBody::Scenario {
|
||||
viewpoint,
|
||||
context: self.context,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: Vec::new(),
|
||||
},
|
||||
);
|
||||
} else if self.viewpoint.is_some() || self.context.is_some() {
|
||||
return Err(
|
||||
"Scenario viewpoint and context cannot be applied to another behavior family."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let element = Element {
|
||||
id: self.element,
|
||||
alias: p.fresh_alias(&self.name, "behavior", self.element),
|
||||
name: self.name.clone(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
};
|
||||
let mut commands = vec![Command::CreateElement(element)];
|
||||
if self.first_view {
|
||||
let working = execute_batch(p, &commands).map_err(|e| e.to_string())?;
|
||||
// Reuse the state editor's geometry contract. A new machine has one
|
||||
// root placement; reserve both presentation IDs across retries.
|
||||
let plan = super::state_edit::create_view(&working, self.element)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Command::Batch(mut view_commands) = plan.command else {
|
||||
return Err("Unexpected state-view plan.".into());
|
||||
};
|
||||
if view_commands.len() != 2 {
|
||||
return Err("Unexpected additional operation in the first state-view plan.".into());
|
||||
}
|
||||
let Some(Command::Layout(layout)) = view_commands.pop() else {
|
||||
return Err("State-view plan must finish with its layout operation.".into());
|
||||
};
|
||||
let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else {
|
||||
return Err("State-view plan is missing its diagram.".into());
|
||||
};
|
||||
if diagram.id != layout || diagram.nodes.len() != 1 {
|
||||
return Err("A first state view must contain only its new machine.".into());
|
||||
}
|
||||
let mut node = diagram
|
||||
.nodes
|
||||
.into_values()
|
||||
.next()
|
||||
.ok_or("State-view root is missing.")?;
|
||||
if node.element != self.element {
|
||||
return Err("State-view root differs from the new machine.".into());
|
||||
}
|
||||
diagram.id = self.diagram;
|
||||
diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram);
|
||||
node.id = self.placement;
|
||||
node.alias = working.fresh_alias(
|
||||
&format!("{} placement", self.name),
|
||||
"placement",
|
||||
self.placement,
|
||||
);
|
||||
diagram.nodes = [(node.id, node)].into_iter().collect();
|
||||
commands.push(Command::CreateDiagram(diagram));
|
||||
commands.push(Command::Layout(self.diagram));
|
||||
}
|
||||
let command = Command::Batch(commands);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Parent maps these local events into the existing guarded Behavior route.
|
||||
/// The card's fields scroll; Create/Cancel stay outside that scroll region.
|
||||
pub fn view<'a>(&'a self, p: &'a Project) -> Option<UiElement<'a, Event>> {
|
||||
self.view_with(p)
|
||||
}
|
||||
fn view_with<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
&'a self,
|
||||
p: &'a Project,
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.draft.as_ref()?;
|
||||
if let Some(view) = d.source.view(p) {
|
||||
return Some(
|
||||
column![
|
||||
text(format!(
|
||||
"State machine name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
view.map(Event::Source)
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut fields = column![].spacing(10);
|
||||
let title = if let Some(family) = d.family {
|
||||
fields = fields.push(
|
||||
text_input("Exact behavior name", &d.name)
|
||||
.id(NAME_INPUT)
|
||||
.on_input(Event::Name)
|
||||
.on_submit(Event::Submit)
|
||||
.padding(8),
|
||||
);
|
||||
match family {
|
||||
Family::Sequence => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
Family::Scenario => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Choose what the lifelines represent:",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
for viewpoint in viewpoints(d.layer) {
|
||||
fields = fields.push(
|
||||
button(text(viewpoint_title(viewpoint)).size(13))
|
||||
.style(if d.viewpoint == Some(viewpoint) {
|
||||
button::primary
|
||||
} else {
|
||||
button::secondary
|
||||
})
|
||||
.on_press(Event::Viewpoint(viewpoint))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
let context_label = if d.context_open {
|
||||
"Hide optional context".into()
|
||||
} else if let Some(id) = d.context {
|
||||
format!("Context: {} · Change…", reference_label(p, id))
|
||||
} else {
|
||||
"Add optional context…".into()
|
||||
};
|
||||
fields = fields.push(
|
||||
button(text(context_label).size(12))
|
||||
.on_press(Event::ContextOptions(!d.context_open))
|
||||
.padding(6),
|
||||
);
|
||||
if d.context_open {
|
||||
fields = fields.push(picker_view(p, d, Picker::Context));
|
||||
}
|
||||
}
|
||||
Family::Machine => {
|
||||
if let Some(label) = d.source.label(d.owner) {
|
||||
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
|
||||
}
|
||||
fields = fields.push(picker_view(p, d, Picker::Owner));
|
||||
fields = fields.push(
|
||||
button("Create new owner…")
|
||||
.on_press(Event::Source(super::source_draft::Event::Open(
|
||||
d.invocation,
|
||||
)))
|
||||
.padding(7),
|
||||
);
|
||||
fields = fields.push(
|
||||
checkbox(d.first_view)
|
||||
.label("Create the first state view")
|
||||
.on_toggle(Event::FirstView),
|
||||
);
|
||||
fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12));
|
||||
}
|
||||
}
|
||||
format!("New {}", family.title(d.layer))
|
||||
} else {
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let purpose = match family {
|
||||
Family::Sequence => "A path through work and exchanges",
|
||||
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
||||
Family::Machine => "States and modes of an explicitly chosen participant",
|
||||
};
|
||||
fields = fields.push(
|
||||
button(
|
||||
column![text(family.title(d.layer)).size(15), text(purpose).size(12)]
|
||||
.spacing(3),
|
||||
)
|
||||
.on_press(Event::Choose(family))
|
||||
.padding(10)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
format!("New behavior · {}", d.layer)
|
||||
};
|
||||
let mut actions =
|
||||
row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8);
|
||||
if d.family.is_some() {
|
||||
actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8));
|
||||
}
|
||||
Some(
|
||||
container(
|
||||
column![
|
||||
text(title).size(20),
|
||||
scrollable(fields)
|
||||
.id("behavior-creation-fields")
|
||||
.height(Fill),
|
||||
container(actions).id("behavior-creation-actions")
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(18)
|
||||
.max_width(680)
|
||||
.height(Fill)
|
||||
.style(crate::style::card)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
p: &'a Project,
|
||||
d: &'a Draft,
|
||||
picker: Picker,
|
||||
) -> UiElement<'a, Event, iced::Theme, R> {
|
||||
let browser = &d.browsers[picker.index()];
|
||||
let options = choices(p, d.layer, picker, &browser.query);
|
||||
let page = browser
|
||||
.page
|
||||
.min(options.len().saturating_sub(1) / PAGE_SIZE);
|
||||
let selected = match picker {
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
|
||||
let title = match picker {
|
||||
Picker::Owner if staged_owner => "Or choose an existing participant",
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
let mut out = column![
|
||||
text(title).size(13),
|
||||
text_input("Search names, aliases or parent context", &browser.query)
|
||||
.on_input(move |query| Event::Search(picker, query))
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
}
|
||||
for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
let message = match picker {
|
||||
Picker::Owner => Event::Owner(e.id),
|
||||
Picker::Context => Event::Context(Some(e.id)),
|
||||
};
|
||||
out = out.push(
|
||||
container(
|
||||
button(
|
||||
column![
|
||||
text(&e.name).size(13),
|
||||
text(reference_context(p, e)).size(11)
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.style(if selected == Some(e.id) {
|
||||
button::primary
|
||||
} else {
|
||||
button::secondary
|
||||
})
|
||||
.on_press(message)
|
||||
.padding(6)
|
||||
.width(Fill),
|
||||
)
|
||||
.id(format!("behavior-creation-candidate-{}", e.id)),
|
||||
);
|
||||
}
|
||||
let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8);
|
||||
if page > 0 {
|
||||
navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1)));
|
||||
}
|
||||
if (page + 1) * PAGE_SIZE < options.len() {
|
||||
navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1)));
|
||||
}
|
||||
if picker == Picker::Context && selected.is_some() {
|
||||
navigation = navigation.push(button("Clear context").on_press(Event::Context(None)));
|
||||
}
|
||||
out.push(navigation).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod app_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,340 +0,0 @@
|
||||
//! App-boundary regressions for the coordinated parent wiring. No I/O task is
|
||||
//! polled: file dialogs, checkpoint writes and lesson loading remain inactive.
|
||||
use super::*;
|
||||
use crate::app::{App, Message, Page, Pending};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(crate::behavior::Event::Creation(event)));
|
||||
}
|
||||
fn raw_property(app: &App) -> serde_json::Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
|
||||
let _ = app.update(Message::Layer(layer));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
send(app, Event::Open(layer));
|
||||
send(app, Event::Choose(family));
|
||||
send(app, Event::Name(name.into()));
|
||||
}
|
||||
|
||||
fn focused_escape() -> Message {
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
use iced::keyboard::{self, key::Named};
|
||||
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
|
||||
.id(NAME_INPUT)
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(Named::Escape),
|
||||
modified_key: keyboard::Key::Named(Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
input.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
assert_eq!(
|
||||
captured_escape(
|
||||
event.clone(),
|
||||
iced::event::Status::Ignored,
|
||||
iced::window::Id::unique()
|
||||
),
|
||||
None
|
||||
);
|
||||
captured_escape(
|
||||
event,
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.unwrap();
|
||||
Message::Escape
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation() {
|
||||
for file in [false, true] {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
begin(
|
||||
&mut app,
|
||||
Layer::Oa,
|
||||
Family::Sequence,
|
||||
"Keep while overlay closes",
|
||||
);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
if file {
|
||||
app.file_prompt = Some(crate::app::FilePrompt {
|
||||
operation: crate::app::FileOperation::Save,
|
||||
path: "unsubmitted-name.arcadia".into(),
|
||||
overwrite: false,
|
||||
});
|
||||
} else {
|
||||
app.pending = Some(Pending::Walkthrough);
|
||||
}
|
||||
let _ = app.update(focused_escape());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(focused_escape());
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_creation_opens_the_actual_new_owner_instead_of_first_or_previously_active_behavior() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let old = p
|
||||
.add_element(Family::Sequence.kind(layer), layer, "Old behavior", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::Select(old));
|
||||
begin(&mut app, layer, Family::Sequence, " New exact path 雨 ");
|
||||
let target = app.behavior.creation.created().unwrap();
|
||||
assert_ne!(target.behavior, old);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Submit);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.selected, target.behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(target.behavior));
|
||||
assert_eq!(crate::behavior::selected_owner(&app), Some(target.behavior));
|
||||
assert_eq!(
|
||||
app.project().elements[&target.behavior].name,
|
||||
" New exact path 雨 "
|
||||
);
|
||||
assert!(!app.behavior.has_pending());
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert_eq!(app.project().elements[&old], p.elements[&old]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_machine_behavior_submit_opens_its_opted_in_view_as_one_undoable_transaction() {
|
||||
for first_view in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "Flight system", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
begin(&mut app, Layer::Sa, Family::Machine, " Flight modes 雨 ");
|
||||
send(&mut app, Event::Owner(owner));
|
||||
send(&mut app, Event::FirstView(first_view));
|
||||
let target = app.behavior.creation.created().unwrap();
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Behavior(crate::behavior::Event::Submit));
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
assert_eq!(app.selected, target.behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(target.behavior));
|
||||
assert_eq!(app.behavior.diagram.diagram, target.diagram);
|
||||
assert_eq!(
|
||||
crate::behavior::state_view::diagram(&app, target.behavior),
|
||||
target.diagram
|
||||
);
|
||||
assert_eq!(app.project().elements[&target.behavior].parent, Some(owner));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.project(), &p);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_stale_layer_open_never_starts_or_replaces_a_draft() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
app.page = Page::Behavior;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Event::Open(Layer::Oa));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
send(&mut app, Event::Open(Layer::Sa));
|
||||
send(&mut app, Event::Choose(Family::Sequence));
|
||||
send(&mut app, Event::Name("Retained system chain".into()));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
send(&mut app, Event::Open(Layer::Oa));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_never_auto_applies_valid_or_invalid_property_and_source_drafts() {
|
||||
for dirty in [
|
||||
"property-valid",
|
||||
"property-invalid",
|
||||
"source-valid",
|
||||
"source-invalid",
|
||||
] {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Behavior;
|
||||
match dirty {
|
||||
"property-valid" => app
|
||||
.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Raw property name".into()),
|
||||
"property-invalid" => app.draft.as_mut().unwrap().input("/id", "-".into()),
|
||||
"source-valid" => app.source_workspace.edit(
|
||||
format!("{}\n// Retained comment\n", app.source_workspace.text()),
|
||||
app.source_editor.cursor(),
|
||||
),
|
||||
_ => app
|
||||
.source_workspace
|
||||
.edit("unfinished source 雨".into(), app.source_editor.cursor()),
|
||||
}
|
||||
let accepted = app.document.snapshot();
|
||||
let property = raw_property(&app);
|
||||
let workspace = app.source_workspace.recovery_snapshot();
|
||||
send(&mut app, Event::Open(Layer::Oa));
|
||||
assert!(
|
||||
!app.behavior.creation.has_pending(),
|
||||
"{dirty}: New must refuse without applying another editor"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(raw_property(&app), property);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
|
||||
serde_json::to_value(workspace).unwrap()
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_choosing_another_behavior_or_layer_cannot_commit_a_creation_draft() {
|
||||
let mut p = Project::empty();
|
||||
let old = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Old process", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
begin(&mut app, Layer::Oa, Family::Sequence, "Retain this process");
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
let _ = app.update(Message::BehaviorOwner(crate::app::Choice {
|
||||
id: old,
|
||||
label: "Old process".into(),
|
||||
}));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_lesson_request_cancel_retains_creation_then_escape_discards_only_the_draft() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
begin(
|
||||
&mut app,
|
||||
Layer::Oa,
|
||||
Family::Sequence,
|
||||
"Unfinished engineering path",
|
||||
);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let task = app.update(Message::Home(crate::home::Event::Walkthrough));
|
||||
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_recovered_rootless_sa_creation_retains_layer_ids_and_other_raw_drafts() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
begin(
|
||||
&mut app,
|
||||
Layer::Sa,
|
||||
Family::Scenario,
|
||||
" Recovered system scenario 雨 ",
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Viewpoint(ScenarioViewpoint::BoundaryInterface),
|
||||
);
|
||||
// Recovery can contain simultaneous drafts even though new input is guarded.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Retained property".into());
|
||||
app.source_workspace.edit(
|
||||
"unfinished source preserved".into(),
|
||||
app.source_editor.cursor(),
|
||||
);
|
||||
let snapshot = crate::recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
None,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap();
|
||||
let expected = snapshot.clone().restore().unwrap();
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let mut restored = App::with_project(Project::empty());
|
||||
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(snapshot))));
|
||||
assert_eq!(restored.page, Page::Behavior);
|
||||
assert_eq!(restored.layer, Layer::Sa);
|
||||
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(
|
||||
restored.behavior.creation.created(),
|
||||
app.behavior.creation.created()
|
||||
);
|
||||
assert_eq!(restored.project(), expected.document.project());
|
||||
assert_eq!(restored.document.bundle(), expected.document.bundle());
|
||||
assert_eq!(
|
||||
restored.source_workspace.bundle(),
|
||||
expected.workspace.bundle()
|
||||
);
|
||||
assert_eq!(raw_property(&restored), raw_property(&app));
|
||||
let before = restored.document.snapshot();
|
||||
send(&mut restored, Event::Submit);
|
||||
assert!(Arc::ptr_eq(&before, &restored.document.snapshot()));
|
||||
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(raw_property(&restored), raw_property(&app));
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn start(p: &Project, layer: Layer, family: Family, name: &str) -> State {
|
||||
let mut state = State::default();
|
||||
assert!(state.update(p, Event::Open(layer)).unwrap().is_none());
|
||||
state.update(p, Event::Choose(family)).unwrap();
|
||||
state.update(p, Event::Name(name.into())).unwrap();
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_creation_keeps_actions_visible_and_renders_only_six_owner_candidates() {
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
struct Geometry {
|
||||
candidates: Vec<widget::Id>,
|
||||
rendered: Vec<widget::Id>,
|
||||
actions: Option<Rectangle>,
|
||||
fields: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("behavior-creation-actions")) {
|
||||
self.actions = Some(bounds);
|
||||
}
|
||||
if let Some(id) = id.filter(|id| self.candidates.contains(id)) {
|
||||
self.rendered.push(id.clone());
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
_: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&widget::Id::new("behavior-creation-fields")) {
|
||||
self.fields = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
for index in 0..105 {
|
||||
p.add_element(
|
||||
Kind::Entity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"{index:03} Long engineering participant name {}",
|
||||
"雨 extended ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let candidates: Vec<_> = p
|
||||
.elements
|
||||
.keys()
|
||||
.map(|id| widget::Id::from(format!("behavior-creation-candidate-{id}")))
|
||||
.collect();
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let state = start(&p, Layer::Oa, family, "Very long exact behavior name 雨");
|
||||
for size in [
|
||||
Size::new(500., 570.),
|
||||
Size::new(1050., 570.),
|
||||
Size::new(500., 320.),
|
||||
] {
|
||||
let mut view: UiElement<'_, Event, iced::Theme, ()> = state.view_with(&p).unwrap();
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(Size::ZERO, size));
|
||||
let mut geometry = Geometry {
|
||||
candidates: candidates.clone(),
|
||||
rendered: Vec::new(),
|
||||
actions: None,
|
||||
fields: None,
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut geometry);
|
||||
let actions = geometry.actions.unwrap();
|
||||
let fields = geometry.fields.unwrap();
|
||||
assert!(actions.x >= 0. && actions.y >= 0.);
|
||||
assert!(
|
||||
actions.x + actions.width <= size.width
|
||||
&& actions.y + actions.height <= size.height
|
||||
);
|
||||
assert!(fields.height >= 100. && fields.y + fields.height <= actions.y);
|
||||
assert_eq!(
|
||||
geometry.rendered.len(),
|
||||
if family == Family::Machine { 6 } else { 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(p: Project, state: &mut State) -> Project {
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
command,
|
||||
state.prepare_pending(&p).unwrap(),
|
||||
"preparation must retain reserved IDs"
|
||||
);
|
||||
let api: Command = serde_json::from_value(serde_json::to_value(&command).unwrap()).unwrap();
|
||||
assert_eq!(api, command);
|
||||
let expected = execute_batch(&p, &[api]).unwrap();
|
||||
let target = state.created().unwrap();
|
||||
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before_source = document.bundle().clone();
|
||||
document.execute(command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert!(
|
||||
state.has_pending(),
|
||||
"only successful acknowledgement clears raw fields"
|
||||
);
|
||||
state.acknowledge_commit();
|
||||
assert!(!state.has_pending());
|
||||
assert!(document.project().elements.contains_key(&target.behavior));
|
||||
let after_source = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(document.bundle(), &before_source);
|
||||
assert!(!document.can_undo(), "one creation must be one Undo entry");
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &after_source);
|
||||
let reopened = Document::from_bundle(after_source, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oa_process_and_sa_chain_keep_exact_names_root_ownership_and_one_history_entry() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let p = Project::empty();
|
||||
let name = " Supply \"water\" — режим ";
|
||||
let mut state = start(&p, layer, Family::Sequence, name);
|
||||
let target = state.created().unwrap();
|
||||
let result = roundtrip(p, &mut state);
|
||||
let element = &result.elements[&target.behavior];
|
||||
assert_eq!(element.name, name);
|
||||
assert_eq!(element.layer(), layer);
|
||||
assert_eq!(
|
||||
element.kind(),
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
}
|
||||
);
|
||||
assert_eq!(element.parent, None);
|
||||
assert!(element.steps().unwrap().is_empty());
|
||||
assert!(result.diagrams.is_empty());
|
||||
assert!(result.relations.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_viewpoints_are_explicit_and_survive_source_history_reopen() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for viewpoint in viewpoints(layer) {
|
||||
let mut p = Project::empty();
|
||||
let context = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Capability
|
||||
} else {
|
||||
Kind::SystemCapability
|
||||
},
|
||||
layer,
|
||||
"Water supply",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = start(&p, layer, Family::Scenario, "Nominal supply");
|
||||
assert!(
|
||||
state
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("explicitly")
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.update(&p, Event::Viewpoint(ScenarioViewpoint::MixedLegacy))
|
||||
.is_err()
|
||||
);
|
||||
let wrong = viewpoints(if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
})[0];
|
||||
assert!(state.update(&p, Event::Viewpoint(wrong)).is_err());
|
||||
state.update(&p, Event::Viewpoint(viewpoint)).unwrap();
|
||||
state.update(&p, Event::Context(Some(context))).unwrap();
|
||||
let id = state.created().unwrap().behavior;
|
||||
let result = roundtrip(p, &mut state);
|
||||
assert_eq!(
|
||||
result.elements[&id].parent, None,
|
||||
"context is not containment"
|
||||
);
|
||||
assert!(
|
||||
matches!(result.elements[&id].shared(), Some(SharedBody::Scenario { viewpoint: actual, context: Some(actual_context), steps, .. }) if *actual == viewpoint && *actual_context == context && steps.is_empty())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_requires_valid_owner_and_first_view_adds_no_region_or_state() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for first_view in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Pump",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let wrong_kind = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
"Pump water",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let opposite = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let wrong_layer = p
|
||||
.add_element(
|
||||
if opposite == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
opposite,
|
||||
"Other layer pump",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = start(&p, layer, Family::Machine, "Pump modes");
|
||||
assert!(
|
||||
state
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("participant")
|
||||
);
|
||||
assert!(state.update(&p, Event::Owner(wrong_kind)).is_err());
|
||||
assert!(state.update(&p, Event::Owner(wrong_layer)).is_err());
|
||||
state.update(&p, Event::Owner(owner)).unwrap();
|
||||
state.update(&p, Event::FirstView(first_view)).unwrap();
|
||||
let target = state.created().unwrap();
|
||||
let expected_count = p.elements.len() + 1;
|
||||
let result = roundtrip(p, &mut state);
|
||||
assert_eq!(result.elements.len(), expected_count);
|
||||
assert_eq!(result.elements[&target.behavior].parent, Some(owner));
|
||||
assert_eq!(result.elements[&target.behavior].kind(), Kind::StateMachine);
|
||||
assert_eq!(result.diagrams.len(), usize::from(first_view));
|
||||
if let Some(view) = target.diagram {
|
||||
let diagram = &result.diagrams[&view];
|
||||
assert_eq!(
|
||||
diagram.projection,
|
||||
ProjectionKind::StateMachine {
|
||||
machine: target.behavior
|
||||
}
|
||||
);
|
||||
assert_eq!(diagram.nodes.len(), 1);
|
||||
assert_eq!(
|
||||
diagram.nodes.values().next().unwrap().element,
|
||||
target.behavior
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_and_illegal_family_layer_switches_never_create_model_objects() {
|
||||
let p = Project::empty();
|
||||
let mut state = start(&p, Layer::Oa, Family::Scenario, "Raw name");
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Viewpoint(ScenarioViewpoint::ParticipantInteraction),
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot = state.recovery_snapshot().unwrap();
|
||||
assert!(state.update(&p, Event::Open(Layer::Sa)).is_err());
|
||||
assert!(state.update(&p, Event::Choose(Family::Sequence)).is_err());
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), snapshot);
|
||||
assert!(state.update(&p, Event::Cancel).unwrap().is_none());
|
||||
assert!(!state.has_pending());
|
||||
assert!(state.created().is_none());
|
||||
assert!(state.prepare_pending(&p).is_err());
|
||||
assert!(p.elements.is_empty());
|
||||
assert!(p.diagrams.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_preserves_exact_raw_inputs_reserved_ids_and_revalidates_stale_owner() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Plant", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, " \nRaw rejected name\0 ");
|
||||
state.update(&p, Event::Owner(owner)).unwrap();
|
||||
state.update(&p, Event::FirstView(true)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Search(Picker::Owner, " Plant ".into()))
|
||||
.unwrap();
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let mut restored = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
|
||||
assert_eq!(restored.created(), state.created());
|
||||
assert_eq!(restored.pending_layer(), Some(Layer::Oa));
|
||||
assert!(restored.prepare_pending(&p).is_err());
|
||||
restored
|
||||
.update(&p, Event::Name("Plant modes".into()))
|
||||
.unwrap();
|
||||
let command = restored.prepare_pending(&p).unwrap();
|
||||
let again = State::from_recovery(restored.recovery_snapshot().unwrap().unwrap()).unwrap();
|
||||
assert_eq!(again.prepare_pending(&p).unwrap(), command);
|
||||
p.elements.remove(&owner);
|
||||
let before = restored.recovery_snapshot().unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("participant")
|
||||
);
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), before);
|
||||
let different = Project::empty();
|
||||
assert!(
|
||||
restored
|
||||
.prepare_pending(&different)
|
||||
.unwrap_err()
|
||||
.contains("another document")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_source_transaction_keeps_draft_and_every_accepted_source_byte() {
|
||||
let p = Project::empty();
|
||||
let initial = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let bytes: usize = initial.bundle().files.values().map(String::len).sum();
|
||||
let limits = ProjectLimits {
|
||||
model: language::ParseLimits {
|
||||
max_source_bytes: bytes + 32,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
},
|
||||
..ProjectLimits::default()
|
||||
};
|
||||
let mut doc = Document::from_bundle(initial.bundle().clone(), limits).unwrap();
|
||||
let mut state = start(
|
||||
&p,
|
||||
Layer::Oa,
|
||||
Family::Sequence,
|
||||
"A long exact name retained after the source budget refuses this new process",
|
||||
);
|
||||
let raw = state.recovery_snapshot().unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
assert!(doc.execute(command).is_err());
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), initial.bundle());
|
||||
assert!(!doc.can_undo());
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), raw);
|
||||
state.update(&p, Event::Cancel).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_search_is_name_ordered_filtered_and_page_sized_not_a_catalog() {
|
||||
let mut p = Project::empty();
|
||||
for index in (0..105).rev() {
|
||||
p.add_element(Kind::Entity, Layer::Oa, &format!("Pump {index:03}"), None)
|
||||
.unwrap();
|
||||
}
|
||||
p.add_element(Kind::System, Layer::Sa, "Pump 999", None)
|
||||
.unwrap();
|
||||
p.add_element(Kind::Activity, Layer::Oa, "Pump work", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
|
||||
let all = choices(&p, Layer::Oa, Picker::Owner, "");
|
||||
assert_eq!(all.len(), 105);
|
||||
assert_eq!(all[0].name, "Pump 000");
|
||||
state
|
||||
.update(&p, Event::Page(Picker::Owner, usize::MAX))
|
||||
.unwrap();
|
||||
assert!(
|
||||
state.view(&p).is_some(),
|
||||
"stale pages are clamped before slicing"
|
||||
);
|
||||
state
|
||||
.update(&p, Event::Search(Picker::Owner, "Pump 104".into()))
|
||||
.unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
|
||||
let found = choices(&p, Layer::Oa, Picker::Owner, "Pump 104");
|
||||
assert_eq!(found.len(), 1);
|
||||
state.update(&p, Event::Owner(found[0].id)).unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().owner, Some(found[0].id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_owner_names_remain_distinguishable_by_searchable_parent_context_and_alias() {
|
||||
let mut p = Project::empty();
|
||||
let north = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "North station", None)
|
||||
.unwrap();
|
||||
let south = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "South station", None)
|
||||
.unwrap();
|
||||
let first = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(north))
|
||||
.unwrap();
|
||||
let second = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(south))
|
||||
.unwrap();
|
||||
let label = reference_label(&p, second);
|
||||
assert_ne!(reference_label(&p, first), label);
|
||||
assert!(label.contains("South station") && label.contains("Pump"));
|
||||
assert!(label.contains(&p.elements[&second].alias));
|
||||
let found = choices(&p, Layer::Oa, Picker::Owner, "south station");
|
||||
assert!(found.iter().any(|e| e.id == second));
|
||||
assert!(!found.iter().any(|e| e.id == first));
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump states");
|
||||
state.update(&p, Event::Owner(second)).unwrap();
|
||||
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
|
||||
assert_eq!(
|
||||
result.elements[&state.created().unwrap().behavior].parent,
|
||||
Some(second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_recovery_and_reserved_identity_collision_refuse_without_repairing_raw_values() {
|
||||
let p = Project::empty();
|
||||
let state = start(&p, Layer::Sa, Family::Sequence, "Supply");
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(99)),
|
||||
("unexpected", serde_json::json!(true)),
|
||||
] {
|
||||
let mut bad = raw.clone();
|
||||
bad[field] = value;
|
||||
assert!(State::from_recovery(bad).is_err());
|
||||
}
|
||||
let mut bad = raw;
|
||||
bad["element"] = serde_json::json!(p.id);
|
||||
let restored = State::from_recovery(bad.clone()).unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("identity")
|
||||
);
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_commit_selection_is_correlated_with_exact_created_behavior_and_view() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Pump", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
|
||||
state.update(&p, Event::Owner(owner)).unwrap();
|
||||
state.update(&p, Event::FirstView(true)).unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
assert_eq!(state.created_by(&p, &command), state.created());
|
||||
assert_eq!(
|
||||
state.created_by(&p, &Command::Rename(owner, "Unrelated change".into())),
|
||||
None
|
||||
);
|
||||
for edit in 0..4 {
|
||||
let mut changed = command.clone();
|
||||
let Command::Batch(ref mut commands) = changed else {
|
||||
panic!("creation batch");
|
||||
};
|
||||
if edit < 3 {
|
||||
let Command::CreateElement(element) = &mut commands[0] else {
|
||||
panic!("new behavior");
|
||||
};
|
||||
match edit {
|
||||
0 => element.id = Id::new_v4(),
|
||||
1 => element.name = "A different authored draft".into(),
|
||||
_ => element.parent = None,
|
||||
}
|
||||
} else {
|
||||
let Command::CreateDiagram(diagram) = &mut commands[1] else {
|
||||
panic!("new view");
|
||||
};
|
||||
diagram.projection = ProjectionKind::StateMachine { machine: owner };
|
||||
}
|
||||
assert_eq!(state.created_by(&p, &changed), None);
|
||||
}
|
||||
let Command::Batch(commands) = command else {
|
||||
panic!("creation batch");
|
||||
};
|
||||
assert_eq!(
|
||||
state.created_by(&p, &commands[0]),
|
||||
None,
|
||||
"an opt-in view must be part of the actual committed batch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_context_collapses_without_losing_authored_reference_or_raw_search() {
|
||||
let mut p = Project::empty();
|
||||
let context = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Water supply", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Scenario, "Supply nominal");
|
||||
assert!(!state.draft.as_ref().unwrap().context_open);
|
||||
state
|
||||
.update(&p, Event::Viewpoint(ScenarioViewpoint::OperationalActivity))
|
||||
.unwrap();
|
||||
state.update(&p, Event::ContextOptions(true)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Search(Picker::Context, " Water ".into()))
|
||||
.unwrap();
|
||||
state.update(&p, Event::Context(Some(context))).unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
state.update(&p, Event::ContextOptions(false)).unwrap();
|
||||
let restored = State::from_recovery(state.recovery_snapshot().unwrap().unwrap()).unwrap();
|
||||
assert!(!restored.draft.as_ref().unwrap().context_open);
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().browsers[1].query,
|
||||
" Water "
|
||||
);
|
||||
assert_eq!(restored.prepare_pending(&p).unwrap(), command);
|
||||
assert_eq!(restored.created_by(&p, &command), restored.created());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_selection_rejects_changed_scenario_body_and_post_creation_operations() {
|
||||
let mut p = Project::empty();
|
||||
let capability = p
|
||||
.add_element(Kind::SystemCapability, Layer::Sa, "Supply", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Sa, Family::Scenario, "Nominal functions");
|
||||
state
|
||||
.update(&p, Event::Viewpoint(ScenarioViewpoint::Functional))
|
||||
.unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
assert_eq!(state.created_by(&p, &command), state.created());
|
||||
for edit in 0..4 {
|
||||
let mut altered = command.clone();
|
||||
let Command::Batch(commands) = &mut altered else {
|
||||
panic!("creation batch");
|
||||
};
|
||||
match edit {
|
||||
0 => {
|
||||
let Command::CreateElement(element) = &mut commands[0] else {
|
||||
panic!("scenario");
|
||||
};
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared_mut() else {
|
||||
panic!("scenario body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::BoundaryInterface;
|
||||
}
|
||||
1 => {
|
||||
let Command::CreateElement(element) = &mut commands[0] else {
|
||||
panic!("scenario");
|
||||
};
|
||||
let Some(SharedBody::Scenario { context, .. }) = element.shared_mut() else {
|
||||
panic!("scenario body");
|
||||
};
|
||||
*context = Some(capability);
|
||||
}
|
||||
2 => commands.push(Command::Rename(
|
||||
state.created().unwrap().behavior,
|
||||
"Changed afterward".into(),
|
||||
)),
|
||||
_ => commands.push(Command::Delete(state.created().unwrap().behavior)),
|
||||
}
|
||||
assert!(
|
||||
execute_batch(&p, std::slice::from_ref(&altered)).is_ok(),
|
||||
"the alternate command is valid, but is not this authored creation"
|
||||
);
|
||||
assert_eq!(state.created_by(&p, &altered), None);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod workspace;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_operation_tests.rs"]
|
||||
@@ -94,13 +92,8 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
First(Context, Tool),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
Zoom(Context, Option<f32>),
|
||||
Focus(Context),
|
||||
Shelf(Context, Shelf),
|
||||
OpenUse(Context, Id),
|
||||
Back(Context),
|
||||
Choose(Context, CanvasTool),
|
||||
@@ -127,19 +120,13 @@ pub enum CanvasEvent {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScrollArea {
|
||||
Canvas,
|
||||
// Retained navigation channel, now the independent Selection/outline shelf.
|
||||
Page,
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::First(context, _)
|
||||
| Self::Scrolled(context, _, _)
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
| Self::Back(context)
|
||||
| Self::Choose(context, _)
|
||||
@@ -164,7 +151,6 @@ impl std::fmt::Display for Operator {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -205,7 +191,6 @@ pub struct State {
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<Caller>,
|
||||
restore: Option<ViewportRestore>,
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
const CALLER_LIMIT: usize = 32;
|
||||
pub(crate) struct Caller {
|
||||
@@ -215,7 +200,6 @@ pub(crate) struct Caller {
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
offsets: [iced::Vector; 2],
|
||||
presentation: workspace::Presentation,
|
||||
}
|
||||
struct ViewportRestore {
|
||||
document: Id,
|
||||
@@ -243,13 +227,6 @@ struct CallChoiceContext {
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
@@ -282,54 +259,6 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
identity: Some((document, owner)),
|
||||
focused: self.presentation.focused,
|
||||
..Default::default()
|
||||
};
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
self.restore = None;
|
||||
}
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
self.presentation.focused
|
||||
}
|
||||
pub(crate) fn toggle_focus(&mut self) {
|
||||
self.presentation.focused = !self.presentation.focused;
|
||||
}
|
||||
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
|
||||
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
|
||||
}
|
||||
pub(crate) fn open_use(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
@@ -350,7 +279,6 @@ impl State {
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
offsets: self.scroll_offsets,
|
||||
presentation: self.presentation.clone(),
|
||||
});
|
||||
self.canvas_tool = Some((target, CanvasTool::Select));
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
@@ -381,8 +309,6 @@ impl State {
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.scroll_offsets = frame.offsets;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.restore = Some(ViewportRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
@@ -452,9 +378,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
}
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
@@ -464,8 +387,7 @@ impl State {
|
||||
changed
|
||||
}
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
// The former page scroll now belongs to a sibling outline shelf.
|
||||
self.scroll_offsets[0]
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
|
||||
let key = CallChoiceContext {
|
||||
@@ -484,16 +406,8 @@ impl State {
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let draft = self.draft.as_ref()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||
match command {
|
||||
Command::CreateElement(element) if element.parent == Some(parent) => {
|
||||
@@ -608,61 +522,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Lifeline {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: d.owner,
|
||||
viewpoint: viewpoint(p, d.owner),
|
||||
},
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -672,9 +531,6 @@ impl State {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
owner,
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -1038,14 +894,7 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
|
||||
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
|
||||
}
|
||||
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&d.owner)
|
||||
.filter(|e| e.kind() == Kind::Scenario)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let layer = owner.layer();
|
||||
let layer = p.elements[&d.owner].layer();
|
||||
let mut values: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1361,28 +1210,6 @@ impl Draft {
|
||||
e
|
||||
}
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: self.owner,
|
||||
viewpoint: viewpoint(p, self.owner),
|
||||
},
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
|
||||
}
|
||||
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
|
||||
return Err("Choose this scenario or one of its operands as destination.".into());
|
||||
}
|
||||
@@ -1393,8 +1220,6 @@ impl Draft {
|
||||
.unwrap_or(self.tool.label());
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -1809,6 +1634,493 @@ fn bounded_picker<'a>(
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
out = out.push(label(match viewpoint(p, owner) {
|
||||
ScenarioViewpoint::OperationalActivity => {
|
||||
"Operational activity scenario · lifelines represent activities"
|
||||
}
|
||||
ScenarioViewpoint::Functional => {
|
||||
"Functional scenario · lifelines represent system functions"
|
||||
}
|
||||
ScenarioViewpoint::ParticipantInteraction => {
|
||||
"Participant scenario · lifelines represent entities, actors or roles"
|
||||
}
|
||||
ScenarioViewpoint::BoundaryInterface => {
|
||||
"Interface scenario · lifelines represent the system and external actors"
|
||||
}
|
||||
ScenarioViewpoint::MixedLegacy => {
|
||||
"Mixed scenario · lifelines may represent work or participants"
|
||||
}
|
||||
}));
|
||||
let pending = app.behavior.has_pending();
|
||||
let context = app.scenario_context().expect("visible scenario context");
|
||||
let tool = app.behavior.scenario.canvas_tool(owner);
|
||||
// Keep the canvas at a stable widget-tree position when a form replaces
|
||||
// the palette, so Iced retains the user's current sequence viewport.
|
||||
let mut controls = column![].spacing(6);
|
||||
if !pending {
|
||||
controls = controls.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Select
|
||||
)))
|
||||
.style(if tool == CanvasTool::Select {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
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),
|
||||
move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool))
|
||||
)
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(240),
|
||||
action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))),
|
||||
action("Properties", Message::EditProperties(owner)),
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
);
|
||||
controls = controls.push(label(match tool {
|
||||
CanvasTool::Message(_) => "Draw message · choose a sequence position on a lifeline, then click or drag to another lifeline · Escape cancels",
|
||||
CanvasTool::Add(Tool::Action | Tool::Observation) => "Click a highlighted sequence position · click on a lifeline to preselect it · Escape cancels",
|
||||
CanvasTool::Add(Tool::Reuse) => "Click a highlighted sequence position to reuse a scenario and bind its lifelines · Escape cancels",
|
||||
CanvasTool::Add(Tool::Fragment) => "Click a highlighted sequence position to insert an empty fragment with operands · Escape cancels",
|
||||
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder within its sequence",
|
||||
}));
|
||||
} else {
|
||||
controls = controls.push(label(
|
||||
"Pending form · the canvas remains visible · apply or cancel below",
|
||||
));
|
||||
}
|
||||
let clean = !pending && !app.draft_dirty() && !app.source_dirty();
|
||||
let mut navigation = row![].spacing(6);
|
||||
if referenced_scenario(p, owner, app.selected).is_some() {
|
||||
navigation = navigation.push(button("Open definition").on_press_maybe(clean.then_some(
|
||||
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, app.selected)),
|
||||
)));
|
||||
}
|
||||
if app.behavior.scenario.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button("Back to caller").on_press_maybe(
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
));
|
||||
}
|
||||
controls = controls.push(navigation.wrap());
|
||||
out = out.push(controls);
|
||||
let mut scene = Scene::sequence(p, owner, app.selected);
|
||||
scene.interaction = Some((
|
||||
context,
|
||||
tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||||
scene.pending_placement = app.behavior.scenario.pending_placement(owner);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
container(super::scenario_scroll::observe(
|
||||
context,
|
||||
ScrollArea::Canvas,
|
||||
app.behavior
|
||||
.scenario
|
||||
.scroll_restore(context, ScrollArea::Canvas),
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 660.).clamp(110., 190.)
|
||||
} else {
|
||||
(app.window_size.height - 410.).clamp(180., 340.)
|
||||
}),
|
||||
))
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
);
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.scenario
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Lifeline {
|
||||
form = form.push(label(format!(
|
||||
"Insert: {}",
|
||||
slot_label(
|
||||
p,
|
||||
Slot {
|
||||
parent: d.parent,
|
||||
before: d.before
|
||||
}
|
||||
)
|
||||
)));
|
||||
if d.before.is_some() {
|
||||
form = form.push(
|
||||
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
|
||||
);
|
||||
}
|
||||
form = form.push(picker(
|
||||
parents(p, owner),
|
||||
Some(d.parent),
|
||||
"Destination sequence / operand…",
|
||||
Event::Parent,
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||||
) {
|
||||
form = form.push(picker(
|
||||
sources(p, d),
|
||||
d.target,
|
||||
"Choose a named source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Message {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
MessageStyle::Asynchronous,
|
||||
MessageStyle::Call,
|
||||
MessageStyle::CallReply,
|
||||
MessageStyle::Reply,
|
||||
MessageStyle::Create,
|
||||
MessageStyle::Delete,
|
||||
MessageStyle::Timer,
|
||||
],
|
||||
Some(d.style),
|
||||
|style| event(Event::Style(style)),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
Event::Call,
|
||||
));
|
||||
if d.call.is_some() {
|
||||
form = form.push(label(
|
||||
"Reply reuses the call exchange in reverse and retains its correlation.",
|
||||
));
|
||||
}
|
||||
} else if d.style != MessageStyle::Timer {
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
d.sender,
|
||||
"Sending lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
if d.style != MessageStyle::Timer {
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, false),
|
||||
d.receiver,
|
||||
"Receiving lifeline…",
|
||||
Event::Receiver,
|
||||
));
|
||||
}
|
||||
if d.style == MessageStyle::Timer {
|
||||
form = form.push(
|
||||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
));
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
}
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
scrollable(form).height((app.window_size.height - 650.).clamp(110., 240.)),
|
||||
footer
|
||||
]
|
||||
.spacing(9),
|
||||
)
|
||||
.padding(14)
|
||||
.style(style::card),
|
||||
);
|
||||
}
|
||||
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
|
||||
out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card));
|
||||
} else if !pending {
|
||||
out = sequence_rows(p, context, owner, out);
|
||||
}
|
||||
super::scenario_scroll::observe(
|
||||
context,
|
||||
ScrollArea::Page,
|
||||
app.behavior
|
||||
.scenario
|
||||
.scroll_restore(context, ScrollArea::Page),
|
||||
scrollable(out.padding(24)),
|
||||
)
|
||||
}
|
||||
fn sequence_rows<'a>(
|
||||
p: &'a Project,
|
||||
context: Context,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Column<'a, Message> {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get(&owner).and_then(|e| e.shared())
|
||||
{
|
||||
for branch in branches {
|
||||
out = out.push(action(
|
||||
&format!("Operand: {}", p.named(*branch)),
|
||||
Message::Select(*branch),
|
||||
));
|
||||
out = sequence_rows(p, context, *branch, out);
|
||||
}
|
||||
}
|
||||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||||
for (i, id) in steps.iter().enumerate() {
|
||||
out =
|
||||
out.push(
|
||||
row![
|
||||
action(
|
||||
&format!("{}. {}", i + 1, p.named(*id)),
|
||||
Message::Select(*id)
|
||||
)
|
||||
.width(Fill),
|
||||
button("↑")
|
||||
.padding(8)
|
||||
.on_press_maybe((i > 0).then_some(Message::Command(
|
||||
Command::MoveStep(owner, i, i.saturating_sub(1))
|
||||
))),
|
||||
button("↓").padding(8).on_press_maybe(
|
||||
(i + 1 < steps.len()).then_some(Message::Command(Command::MoveStep(
|
||||
owner,
|
||||
i,
|
||||
i + 1
|
||||
)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(target),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements[id].shared()
|
||||
{
|
||||
out = out.push(action(
|
||||
&format!("Open {} →", p.named(*target)),
|
||||
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
|
||||
));
|
||||
}
|
||||
out = sequence_rows(p, context, *id, out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod direct_tests {
|
||||
@@ -1868,7 +2180,7 @@ pub(crate) mod direct_tests {
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
||||
fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
||||
let mut f = fixture();
|
||||
let p = &mut f.project;
|
||||
let machine = p
|
||||
@@ -2022,12 +2334,7 @@ pub(crate) mod direct_tests {
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 170.)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.scroll_offsets[1],
|
||||
iced::Vector::new(0., 80.),
|
||||
"The caller's independent outline scroll is restored separately"
|
||||
iced::Vector::new(25., 250.)
|
||||
);
|
||||
assert!(
|
||||
!app.behavior
|
||||
@@ -2164,11 +2471,6 @@ pub(crate) mod direct_tests {
|
||||
assert!(app.document.can_undo(), "{}", app.status);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
assert_eq!(app.selected, if edit == 0 { first } else { b });
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().scenario,
|
||||
if edit == 0 { a } else { b },
|
||||
"Back must restore the caller workspace even if a rename already selected its UUID"
|
||||
);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
@@ -2607,7 +2909,7 @@ pub(crate) mod direct_tests {
|
||||
assert!(app.busy);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(
|
||||
app.scenario_context(),
|
||||
@@ -2623,7 +2925,7 @@ pub(crate) mod direct_tests {
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
iced::Vector::new(25., 100.)
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
|
||||
let receipt = crate::app::FileSnapshot {
|
||||
|
||||
@@ -1,810 +0,0 @@
|
||||
//! Scenario presentation. Layout/camera controls never construct model commands.
|
||||
use super::super::scenario_scroll;
|
||||
use super::*;
|
||||
use iced::widget::{Space, column, sensor};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Shelf {
|
||||
Tools,
|
||||
Selection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Presentation {
|
||||
pub(super) identity: Option<(Id, Id)>,
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) focused: bool,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
const MIN_SCALE: f32 = 0.05;
|
||||
impl Presentation {
|
||||
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
|| !size.height.is_finite()
|
||||
|| size.width <= 0.
|
||||
|| size.height <= 0.
|
||||
|| self.viewport == Some(size)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.viewport = Some(size);
|
||||
true
|
||||
}
|
||||
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
||||
let changed = self.zoom != next || self.fit != scale.is_none();
|
||||
self.zoom = next;
|
||||
self.fit = scale.is_none();
|
||||
changed
|
||||
}
|
||||
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.
|
||||
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 {
|
||||
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> {
|
||||
let p = app.project();
|
||||
let state = &app.behavior.scenario;
|
||||
let presentation = &state.presentation;
|
||||
let pending = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.owner == owner);
|
||||
let context = app.scenario_context().expect("visible scenario context");
|
||||
let tool = state.canvas_tool(owner);
|
||||
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.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 size = Size::new(scene.size.width * scale, scene.size.height * scale);
|
||||
let canvas = canvas_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Canvas),
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
);
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
button(
|
||||
text(if presentation.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.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 limited {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
|
||||
}
|
||||
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,
|
||||
);
|
||||
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.scenario
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Lifeline {
|
||||
form = form.push(label(format!(
|
||||
"Insert: {}",
|
||||
slot_label(
|
||||
p,
|
||||
Slot {
|
||||
parent: d.parent,
|
||||
before: d.before
|
||||
}
|
||||
)
|
||||
)));
|
||||
if d.before.is_some() {
|
||||
form = form.push(
|
||||
button("Append to this sequence instead")
|
||||
.on_press(event(Event::AppendInstead)),
|
||||
);
|
||||
}
|
||||
form = form.push(picker(
|
||||
parents(p, owner),
|
||||
Some(d.parent),
|
||||
"Destination sequence / operand…",
|
||||
Event::Parent,
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||||
) {
|
||||
form = form.push(picker(
|
||||
sources(p, d),
|
||||
d.target,
|
||||
"Choose a named source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Lifeline {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label(
|
||||
"Create commits one new source and this lifeline together.",
|
||||
));
|
||||
}
|
||||
if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy {
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
} else {
|
||||
form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available."));
|
||||
}
|
||||
}
|
||||
if d.tool == Tool::Message {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
MessageStyle::Asynchronous,
|
||||
MessageStyle::Call,
|
||||
MessageStyle::CallReply,
|
||||
MessageStyle::Reply,
|
||||
MessageStyle::Create,
|
||||
MessageStyle::Delete,
|
||||
MessageStyle::Timer,
|
||||
],
|
||||
Some(d.style),
|
||||
|style| event(Event::Style(style)),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
Event::Call,
|
||||
));
|
||||
if d.call.is_some() {
|
||||
form = form.push(label(
|
||||
"Reply reuses the call exchange in reverse and retains its correlation.",
|
||||
));
|
||||
}
|
||||
} else if d.style != MessageStyle::Timer {
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
d.sender,
|
||||
"Sending lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
if d.style != MessageStyle::Timer {
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, false),
|
||||
d.receiver,
|
||||
"Receiving lifeline…",
|
||||
Event::Receiver,
|
||||
));
|
||||
}
|
||||
if d.style == MessageStyle::Timer {
|
||||
form = form.push(
|
||||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
));
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
}
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(footer.into(), form.into());
|
||||
}
|
||||
}
|
||||
workspace_layout(header.into(), details, canvas, form_panel, pending)
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
title: &str,
|
||||
message: Message,
|
||||
enabled: bool,
|
||||
) -> iced::widget::Button<'a, Message> {
|
||||
button(text(title.to_owned()).size(12))
|
||||
.padding(8)
|
||||
.style(style::nav)
|
||||
.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,
|
||||
ScrollArea::Page,
|
||||
restore,
|
||||
scrollable(details)
|
||||
.id("scenario-details")
|
||||
.width(Fill)
|
||||
.height(if open {
|
||||
if pending { 72. } else { 100. }
|
||||
} else {
|
||||
0.
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
context: Context,
|
||||
restore: Option<ScrollRestore>,
|
||||
content: iced::Element<'a, Message, Theme, R>,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
container(
|
||||
sensor(scenario_scroll::observe(
|
||||
context,
|
||||
ScrollArea::Canvas,
|
||||
restore,
|
||||
scrollable(content)
|
||||
.id("scenario-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
))
|
||||
// A new owner/document needs its first measurement even at the same size.
|
||||
.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>(
|
||||
actions: iced::Element<'a, Message, Theme, R>,
|
||||
fields: iced::Element<'a, Message, Theme, R>,
|
||||
) -> iced::Element<'a, Message, Theme, R> {
|
||||
container(
|
||||
column![
|
||||
container(actions).id("scenario-actions").width(Fill),
|
||||
scrollable(fields)
|
||||
.id("scenario-form")
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
|
||||
fn sequence_rows<'a>(
|
||||
p: &'a Project,
|
||||
context: Context,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
enabled: bool,
|
||||
) -> Column<'a, Message> {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get(&owner).and_then(|e| e.shared())
|
||||
{
|
||||
for branch in branches {
|
||||
out = out.push(sequence_action(
|
||||
&format!("Operand: {}", p.named(*branch)),
|
||||
Message::Select(*branch),
|
||||
enabled,
|
||||
));
|
||||
out = sequence_rows(p, context, *branch, out, enabled);
|
||||
}
|
||||
}
|
||||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||||
for (i, id) in steps.iter().enumerate() {
|
||||
out = out.push(
|
||||
row![
|
||||
sequence_action(
|
||||
&format!("{}. {}", i + 1, p.named(*id)),
|
||||
Message::Select(*id),
|
||||
enabled
|
||||
)
|
||||
.width(Fill),
|
||||
button("↑")
|
||||
.padding(8)
|
||||
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
|
||||
Command::MoveStep(owner, i, i.saturating_sub(1))
|
||||
))),
|
||||
button("↓").padding(8).on_press_maybe(
|
||||
(enabled && i + 1 < steps.len())
|
||||
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(target),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements[id].shared()
|
||||
{
|
||||
out = out.push(sequence_action(
|
||||
&format!("Open {} →", p.named(*target)),
|
||||
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
out = sequence_rows(p, context, *id, out, enabled);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,900 +0,0 @@
|
||||
use super::*;
|
||||
use iced::Vector;
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
scrolls: BTreeMap<String, (Rectangle, Vector)>,
|
||||
actions: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("scenario-actions")) {
|
||||
self.actions = Some(bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
for name in ["scenario-canvas", "scenario-details", "scenario-form"] {
|
||||
if id == Some(&widget::Id::new(name)) {
|
||||
assert!(
|
||||
self.scrolls
|
||||
.insert(name.into(), (bounds, translation))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
type TestView = iced::Element<'static, Message, Theme, ()>;
|
||||
fn fixture_view(state: &State, context: Context, pending: bool, header: f32) -> TestView {
|
||||
workspace_layout(
|
||||
Space::new().height(header).into(),
|
||||
details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
Space::new().height(1800.).into(),
|
||||
state.presentation.shelf.is_some(),
|
||||
pending,
|
||||
),
|
||||
canvas_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Canvas),
|
||||
Space::new().width(1800.).height(2400.).into(),
|
||||
),
|
||||
editor_panel(
|
||||
Space::new().height(32.).into(),
|
||||
Space::new().height(2000.).into(),
|
||||
),
|
||||
pending,
|
||||
)
|
||||
}
|
||||
fn geometry(view: &mut TestView, tree: &mut widget::Tree, size: Size) -> (layout::Node, Geometry) {
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, size));
|
||||
let mut geometry = Geometry::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut geometry);
|
||||
(node, geometry)
|
||||
}
|
||||
fn scroll(
|
||||
view: &mut TestView,
|
||||
tree: &mut widget::Tree,
|
||||
node: &layout::Node,
|
||||
name: &'static str,
|
||||
offset: Vector,
|
||||
) {
|
||||
let mut operation = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new(name),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: Some(offset.x),
|
||||
y: Some(offset.y),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(node), &(), &mut operation);
|
||||
}
|
||||
fn redraw(view: &mut TestView, tree: &mut widget::Tree, size: Size, app: &mut App) {
|
||||
let (node, _) = geometry(view, tree, size);
|
||||
let mut messages = vec![];
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_allocates_remaining_height_and_pins_actions_at_minimum_and_wide_sizes() {
|
||||
for size in [
|
||||
Size::new(500., 570.),
|
||||
Size::new(1050., 570.),
|
||||
Size::new(840., 770.),
|
||||
Size::new(1394., 802.),
|
||||
] {
|
||||
for pending in [false, true] {
|
||||
for shelf in [None, Some(Shelf::Tools), Some(Shelf::Selection)] {
|
||||
for header in [68., 100., 132.] {
|
||||
let mut state = State::default();
|
||||
state.presentation.shelf = shelf;
|
||||
let mut view = fixture_view(
|
||||
&state,
|
||||
super::super::direct_tests::context(Id::new_v4()),
|
||||
pending,
|
||||
header,
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(node.size(), size);
|
||||
let canvas = actual.scrolls["scenario-canvas"].0;
|
||||
assert!(canvas.x >= 0. && canvas.y >= header);
|
||||
assert!(
|
||||
canvas.x + canvas.width <= size.width
|
||||
&& canvas.y + canvas.height <= size.height
|
||||
);
|
||||
assert!(
|
||||
canvas.height >= if pending { 145. } else { 290. },
|
||||
"{size:?} header={header} pending={pending} shelf={shelf:?}: {canvas:?}"
|
||||
);
|
||||
if pending {
|
||||
let form = actual.scrolls["scenario-form"].0;
|
||||
let actions = actual.actions.unwrap();
|
||||
assert!(form.height >= 100. && form.y + form.height <= size.height);
|
||||
assert!(actions.y >= canvas.y + canvas.height);
|
||||
assert!(actions.y + actions.height <= form.y);
|
||||
scroll(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
&node,
|
||||
"scenario-form",
|
||||
Vector::new(0., 600.),
|
||||
);
|
||||
let (_, after) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(after.actions, Some(actions));
|
||||
assert_eq!(after.scrolls["scenario-form"].1.y, 600.);
|
||||
assert_eq!(after.scrolls["scenario-canvas"].1, Vector::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_canvas_and_form_scroll_survive_disclosures_focus_width_and_resize() {
|
||||
let context = super::super::direct_tests::context(Id::new_v4());
|
||||
let mut state = State::default();
|
||||
let mut view = fixture_view(&state, context, false, 68.);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let offset = Vector::new(90., 180.);
|
||||
scroll(&mut view, &mut tree, &node, "scenario-canvas", offset);
|
||||
let mut prior_form = false;
|
||||
for (pending, shelf, header, size) in [
|
||||
(false, Some(Shelf::Tools), 100., Size::new(500., 570.)),
|
||||
(true, Some(Shelf::Selection), 68., Size::new(1050., 570.)),
|
||||
(true, None, 68., Size::new(1394., 802.)),
|
||||
(true, Some(Shelf::Tools), 100., Size::new(500., 570.)),
|
||||
(false, None, 100., Size::new(500., 570.)),
|
||||
] {
|
||||
state.presentation.shelf = shelf;
|
||||
view = fixture_view(&state, context, pending, header);
|
||||
tree.diff(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(actual.scrolls["scenario-canvas"].1, offset);
|
||||
if pending {
|
||||
if prior_form {
|
||||
assert_eq!(actual.scrolls["scenario-form"].1.y, 600.);
|
||||
}
|
||||
scroll(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
&node,
|
||||
"scenario-form",
|
||||
Vector::new(0., 600.),
|
||||
);
|
||||
let (_, after) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(after.actions, actual.actions);
|
||||
assert_eq!(after.scrolls["scenario-canvas"].1, offset);
|
||||
}
|
||||
prior_form = pending;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_caller_restores_actual_mounted_canvas_and_shelf_with_camera() {
|
||||
use super::super::direct_tests::{navigation_event, navigation_fixture};
|
||||
let (p, [a, b, _], [reference, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75)));
|
||||
let source = app.document.snapshot();
|
||||
let size = Size::new(1050., 570.);
|
||||
let mut view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (node, _) = geometry(&mut view, &mut tree, size);
|
||||
let canvas_offset = Vector::new(90., 180.);
|
||||
scroll(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
&node,
|
||||
"scenario-canvas",
|
||||
canvas_offset,
|
||||
);
|
||||
scroll(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
&node,
|
||||
"scenario-details",
|
||||
Vector::new(0., 80.),
|
||||
);
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.scroll_offset(),
|
||||
canvas_offset,
|
||||
"shelf is not a canvas ancestor"
|
||||
);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
assert_eq!(app.selected, b);
|
||||
view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (_, opened) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.)));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
assert_eq!(app.selected, reference);
|
||||
assert!(app.behavior.scenario.focused());
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.shelf,
|
||||
Some(Shelf::Selection)
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior
|
||||
.scenario
|
||||
.presentation
|
||||
.scale(Size::new(800., 1200.)),
|
||||
0.75
|
||||
);
|
||||
view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (node, returned) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(returned.scrolls["scenario-canvas"].1, canvas_offset);
|
||||
assert_eq!(returned.scrolls["scenario-details"].1, Vector::new(0., 80.));
|
||||
for area in [ScrollArea::Canvas, ScrollArea::Page] {
|
||||
assert!(
|
||||
app.behavior
|
||||
.scenario
|
||||
.scroll_restore(app.scenario_context().unwrap(), area)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
scroll(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
&node,
|
||||
"scenario-canvas",
|
||||
Vector::new(90., 220.),
|
||||
);
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
assert_eq!(
|
||||
geometry(&mut view, &mut tree, size).1.scrolls["scenario-canvas"]
|
||||
.1
|
||||
.y,
|
||||
220.,
|
||||
"receipt cannot snap a later gesture back"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&source, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs() {
|
||||
let graph = Size::new(900., 1400.);
|
||||
let mut presentation = Presentation::default();
|
||||
assert_eq!(presentation.scale(graph), 1.);
|
||||
presentation.zoom(None);
|
||||
assert_eq!(
|
||||
presentation.scale(graph),
|
||||
1.,
|
||||
"no invented window-height fallback"
|
||||
);
|
||||
assert!(presentation.viewport(Size::new(484., 204.)));
|
||||
assert!(!presentation.viewport(Size::new(484., 204.)));
|
||||
let scale = presentation.scale(graph);
|
||||
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
|
||||
assert!(!presentation.fit_limited(graph));
|
||||
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
|
||||
assert!(!presentation.zoom(Some(bad)));
|
||||
assert!(!presentation.viewport(Size::new(bad, 100.)));
|
||||
assert!(!presentation.viewport(Size::new(100., bad)));
|
||||
assert_eq!(presentation.scale(graph), scale);
|
||||
}
|
||||
assert!(presentation.fit_limited(Size::new(900., 10000.)));
|
||||
assert!(presentation.zoom(Some(1.25)));
|
||||
presentation.viewport(Size::new(200., 100.));
|
||||
assert_eq!(
|
||||
presentation.scale(graph),
|
||||
1.25,
|
||||
"manual zoom survives form/viewport resize"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_fit_reobserves_same_sized_owner_and_document_changes() {
|
||||
use super::super::direct_tests::{navigation_event, navigation_fixture};
|
||||
let (project, [a, b, _], [reference, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let size = Size::new(500., 570.);
|
||||
let graph = Size::new(1800., 2400.);
|
||||
let mut view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (_, first) = geometry(&mut view, &mut tree, size);
|
||||
let measured = first.scrolls["scenario-canvas"].0.size();
|
||||
assert_eq!(app.behavior.scenario.presentation.viewport, Some(measured));
|
||||
|
||||
let accepted = app.document.snapshot();
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, b);
|
||||
assert_eq!(app.behavior.scenario.presentation.viewport, None);
|
||||
view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (_, second) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(second.scrolls["scenario-canvas"].0.size(), measured);
|
||||
assert_eq!(app.behavior.scenario.presentation.viewport, Some(measured));
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
let fitted = app.behavior.scenario.presentation.scale(graph);
|
||||
assert!(
|
||||
fitted < 1.
|
||||
&& graph.width * fitted <= measured.width - 16.
|
||||
&& graph.height * fitted <= measured.height - 16.
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
|
||||
let document = app.scenario_context().unwrap().document;
|
||||
let mut replacement = App::with_project(project);
|
||||
let _ = replacement.update(Message::Select(b));
|
||||
let _ = replacement.update(Message::Page(crate::app::Page::Behavior));
|
||||
assert_ne!(replacement.scenario_context().unwrap().document, document);
|
||||
assert_eq!(replacement.behavior.scenario.presentation.viewport, None);
|
||||
let accepted = replacement.document.snapshot();
|
||||
view = fixture_view(
|
||||
&replacement.behavior.scenario,
|
||||
replacement.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut replacement);
|
||||
let (_, replaced) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(replaced.scrolls["scenario-canvas"].0.size(), measured);
|
||||
assert_eq!(
|
||||
replacement.behavior.scenario.presentation.viewport,
|
||||
Some(measured)
|
||||
);
|
||||
navigation_event(&mut replacement, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert_eq!(
|
||||
replacement.behavior.scenario.presentation.scale(graph),
|
||||
fitted
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&accepted,
|
||||
&replacement.document.snapshot()
|
||||
));
|
||||
assert!(!replacement.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_open_and_back_restore_owner_and_mounted_scroll_after_preselection() {
|
||||
use super::super::direct_tests::{navigation_event, navigation_fixture};
|
||||
let (p, [a, b, _], [reference, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75)));
|
||||
let size = Size::new(1050., 570.);
|
||||
let mut view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (node, _) = geometry(&mut view, &mut tree, size);
|
||||
let offset = Vector::new(90., 180.);
|
||||
scroll(&mut view, &mut tree, &node, "scenario-canvas", offset);
|
||||
scroll(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
&node,
|
||||
"scenario-details",
|
||||
Vector::new(0., 80.),
|
||||
);
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
b,
|
||||
"Renamed definition".into(),
|
||||
)));
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, a);
|
||||
let before_open = app.document.snapshot();
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, b);
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&before_open,
|
||||
&app.document.snapshot()
|
||||
));
|
||||
view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (_, opened) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO);
|
||||
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
reference,
|
||||
"Renamed caller use".into(),
|
||||
)));
|
||||
assert_eq!(app.selected, reference);
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, b);
|
||||
let before_back = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
assert_eq!(app.selected, reference);
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, a);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.canvas_tool(a),
|
||||
CanvasTool::Message(MessageStyle::Timer)
|
||||
);
|
||||
assert!(app.behavior.scenario.focused());
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.shelf,
|
||||
Some(Shelf::Selection)
|
||||
);
|
||||
assert_eq!(app.behavior.scenario.presentation.zoom, Some(0.75));
|
||||
view = fixture_view(
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
false,
|
||||
68.,
|
||||
);
|
||||
tree.diff(view.as_widget());
|
||||
redraw(&mut view, &mut tree, size, &mut app);
|
||||
let (_, restored) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(restored.scrolls["scenario-canvas"].1, offset);
|
||||
assert_eq!(restored.scrolls["scenario-details"].1, Vector::new(0., 80.));
|
||||
assert_eq!(app.behavior.scenario.scroll_offset(), offset);
|
||||
assert!(app.behavior.scenario.restore.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&before_back,
|
||||
&app.document.snapshot()
|
||||
));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_forms() {
|
||||
use super::super::direct_tests::{fixture, navigation_event};
|
||||
let f = fixture();
|
||||
let mut app = App::with_project(f.project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let _ = app.update(event(Event::Start(f.owner, Tool::Message)));
|
||||
let _ = app.update(event(Event::Name(" raw 雨 ".into())));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let context = app.scenario_context().unwrap();
|
||||
app.busy = true;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
Size::new(500., 200.),
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(context));
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
Size::new(500., 200.),
|
||||
)));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
for mismatch in 0..3 {
|
||||
let mut stale = context;
|
||||
match mismatch {
|
||||
0 => stale.document = Id::new_v4(),
|
||||
1 => stale.scenario = Id::new_v4(),
|
||||
_ => stale.revision += 1,
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
stale,
|
||||
Size::new(900., 400.),
|
||||
)));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
app.busy = false;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Tools));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_choosing_tools_preserves_valid_and_invalid_pending_editors() {
|
||||
use super::super::direct_tests::{fixture, navigation_event};
|
||||
for editor in 0..3 {
|
||||
for valid in [false, true] {
|
||||
let f = fixture();
|
||||
let mut app = App::with_project(f.project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
match editor {
|
||||
0 => {
|
||||
let _ = app.update(Message::Field(
|
||||
if valid { "/name" } else { "/id" }.into(),
|
||||
if valid {
|
||||
" Retained property 雨 "
|
||||
} else {
|
||||
"not-a-uuid"
|
||||
}
|
||||
.into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
assert_eq!(app.draft.as_ref().unwrap().command().is_ok(), valid);
|
||||
}
|
||||
1 => {
|
||||
let raw = if valid {
|
||||
format!("// Retained source 雨\n{}", app.source_editor.text())
|
||||
} else {
|
||||
"? invalid source 雨".into()
|
||||
};
|
||||
let _ = app.update(Message::SourceEdit(
|
||||
iced::widget::text_editor::Action::SelectAll,
|
||||
));
|
||||
let _ = app.update(Message::SourceEdit(
|
||||
iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Paste(std::sync::Arc::new(raw)),
|
||||
),
|
||||
));
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(
|
||||
crate::source_workspace::compile(&app.source_workspace.bundle()).is_ok(),
|
||||
valid
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let _ = app.update(event(Event::Start(
|
||||
f.owner,
|
||||
if valid { Tool::Fragment } else { Tool::Action },
|
||||
)));
|
||||
let _ = app.update(event(Event::Name(" Retained behavior 雨 ".into())));
|
||||
let _ = app.update(event(Event::Expression("ready".into())));
|
||||
let _ = app.update(event(Event::SecondGuard("otherwise".into())));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(
|
||||
app.behavior
|
||||
.scenario
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.command(app.project())
|
||||
.is_ok(),
|
||||
valid
|
||||
);
|
||||
}
|
||||
}
|
||||
let source = serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap();
|
||||
let properties = app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap());
|
||||
let behavior = app.behavior.recovery_snapshot().unwrap();
|
||||
let text = app.source_editor.text();
|
||||
let accepted = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
let context = app.scenario_context();
|
||||
let tool = app.behavior.scenario.canvas_tool(f.owner);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
assert!(
|
||||
app.status.contains("Apply or cancel"),
|
||||
"editor={editor} valid={valid}: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), text);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
|
||||
source
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap()),
|
||||
properties
|
||||
);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert_eq!(app.behavior.scenario.canvas_tool(f.owner), tool);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_whole_app_canvas_tree_survives_pending_shelves_and_focus() {
|
||||
use iced::advanced::widget::tree;
|
||||
use std::rc::Rc;
|
||||
fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||
if tree.tag == tag {
|
||||
return Some(vec![]);
|
||||
}
|
||||
tree.children.iter().enumerate().find_map(|(i, child)| {
|
||||
let mut path = path_to(child, tag)?;
|
||||
path.insert(0, i);
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree {
|
||||
for &index in path {
|
||||
tree = &mut tree.children[index];
|
||||
}
|
||||
tree
|
||||
}
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut app = App::with_project(f.project);
|
||||
let _ = app.update(Message::Select(f.call));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let canvas: Element<'_, Message> =
|
||||
canvas::Canvas::new(Scene::sequence(app.project(), f.owner, f.call)).into();
|
||||
let tag = widget::Tree::new(canvas.as_widget()).tag;
|
||||
drop(canvas);
|
||||
let mut tree = widget::Tree::new(crate::views::view(&app).as_widget());
|
||||
let mut path = path_to(&tree, tag).unwrap();
|
||||
path.pop();
|
||||
let marker = Rc::new(());
|
||||
at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker));
|
||||
let _ = app.update(event(Event::Start(f.owner, Tool::Message)));
|
||||
let _ = app.update(event(Event::Name(" untouched 雨 ".into())));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] {
|
||||
super::super::direct_tests::navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Shelf(ctx, shelf)
|
||||
});
|
||||
super::super::direct_tests::navigation_event(&mut app, CanvasEvent::Focus);
|
||||
tree.diff(crate::views::view(&app).as_widget());
|
||||
let mut current = path_to(&tree, tag).unwrap();
|
||||
current.pop();
|
||||
assert_eq!(current, path);
|
||||
assert_eq!(Rc::strong_count(&marker), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
at(&mut tree, &path).state.downcast_ref::<Rc<()>>(),
|
||||
&marker
|
||||
));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(app.selected, f.call);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_endpoints() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
for scale in [0.25, 0.5, 1., 2.] {
|
||||
for drag in [false, true] {
|
||||
let mut scene = Scene::sequence(&f.project, f.owner, f.call);
|
||||
let context = super::super::direct_tests::context(f.owner);
|
||||
scene.scale = scale;
|
||||
scene.scroll_offset = Vector::new(35., 40.);
|
||||
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
|
||||
let slot = scene.slots.last().unwrap();
|
||||
let expected = slot.slot;
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(20., 80.),
|
||||
Size::new(scene.size.width * scale, scene.size.height * scale),
|
||||
);
|
||||
let screen = |world: Point| {
|
||||
Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale)
|
||||
- scene.scroll_offset
|
||||
};
|
||||
let a = screen(Point::new(scene.lanes[&f.sender], slot.y));
|
||||
let b = screen(Point::new(scene.lanes[&f.receiver], slot.y));
|
||||
let mut input = super::super::super::SequenceInput::default();
|
||||
let mut messages = vec![];
|
||||
let mut events = vec![
|
||||
(mouse::Event::CursorMoved { position: a }, a),
|
||||
(mouse::Event::ButtonPressed(mouse::Button::Left), a),
|
||||
];
|
||||
if !drag {
|
||||
events.push((mouse::Event::ButtonReleased(mouse::Button::Left), a));
|
||||
}
|
||||
events.push((mouse::Event::CursorMoved { position: b }, b));
|
||||
if !drag {
|
||||
events.push((mouse::Event::ButtonPressed(mouse::Button::Left), b));
|
||||
}
|
||||
events.push((mouse::Event::ButtonReleased(mouse::Button::Left), b));
|
||||
for (event, cursor) in events {
|
||||
if let Some(action) = canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(cursor + scene.scroll_offset),
|
||||
) && let Some(message) = action.into_inner().0
|
||||
{
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Begin { sender, receiver, slot, .. })]
|
||||
if *sender == f.sender && *receiver == f.receiver && *slot == expected),
|
||||
"scale={scale} drag={drag}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_identity() {
|
||||
let (f, branch, anchor, _, _, _) = super::super::direct_tests::placement_fixture();
|
||||
for scale in [0.25, 0.5, 1., 2.] {
|
||||
let mut scene = Scene::sequence(&f.project, f.owner, f.call);
|
||||
let context = super::super::direct_tests::context(f.owner);
|
||||
scene.scale = scale;
|
||||
scene.scroll_offset = Vector::new(35., 40.);
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(20., 80.),
|
||||
Size::new(scene.size.width * scale, scene.size.height * scale),
|
||||
);
|
||||
let screen = |world: Point| {
|
||||
Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale)
|
||||
- Vector::new(35., 40.)
|
||||
};
|
||||
let send =
|
||||
|scene: &Scene, input: &mut super::super::super::SequenceInput, event, cursor| {
|
||||
canvas::Program::update(
|
||||
scene,
|
||||
input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(cursor + scene.scroll_offset),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
};
|
||||
for tool in [Tool::Action, Tool::Observation, Tool::Reuse, Tool::Fragment] {
|
||||
scene.interaction = Some((context, CanvasTool::Add(tool), true));
|
||||
for expected in [
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.reply),
|
||||
},
|
||||
Slot {
|
||||
parent: branch,
|
||||
before: Some(anchor),
|
||||
},
|
||||
] {
|
||||
let slot = scene
|
||||
.slots
|
||||
.iter()
|
||||
.find(|slot| slot.slot == expected)
|
||||
.unwrap();
|
||||
let point = screen(Point::new(scene.lanes[&f.sender], slot.y));
|
||||
let mut input = super::super::super::SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
point,
|
||||
);
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let message = send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
point,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(message, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { tool: captured, slot, lifeline, .. })
|
||||
if captured == tool && slot == expected && lifeline == tool.uses_lifeline().then_some(f.sender)),
|
||||
"scale={scale} tool={tool:?} slot={expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let mut input = super::super::super::SequenceInput::default();
|
||||
let expected = scene
|
||||
.slots
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|slot| slot.slot.parent == f.owner)
|
||||
.unwrap();
|
||||
let start = screen(scene.reorder_handle().unwrap().center());
|
||||
let end = screen(Point::new(17., expected.y));
|
||||
send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
start,
|
||||
);
|
||||
send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
start,
|
||||
);
|
||||
send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end,
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&scene, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), end),
|
||||
Some(Message::ScenarioCanvas(CanvasEvent::Reorder { occurrence, slot, .. }))
|
||||
if occurrence == f.call && slot == expected.slot),
|
||||
"scale={scale}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,6 @@ impl Tool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool, Option<Id>),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -91,22 +90,15 @@ pub struct State {
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
}
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
#[serde(with = "recovery_insertion")]
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
@@ -125,28 +117,6 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
@@ -182,58 +152,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Work {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Work { owner: d.owner },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -266,9 +184,6 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
@@ -593,22 +508,6 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Work { owner: self.owner },
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
let owner = p
|
||||
.elements
|
||||
.get(&self.owner)
|
||||
@@ -716,8 +615,6 @@ impl Draft {
|
||||
});
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default_name
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -1690,9 +1587,6 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: None,
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
|
||||
@@ -33,7 +33,7 @@ pub enum Event {
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
|
||||
@@ -206,16 +206,8 @@ impl State {
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
|
||||
@@ -369,25 +369,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
.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(),
|
||||
@@ -402,19 +383,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
@@ -466,14 +434,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
}),
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Work {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
|
||||
}
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
@@ -619,7 +579,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
}
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
//! Publication-and-visit-bound input for a mounted diagnostic list.
|
||||
//!
|
||||
//! Keep this wrapper at the same widget-tree position, inside the list's outer
|
||||
//! scroller. Pass `None` with an empty child while hidden/removed; unmounting the
|
||||
//! wrapper loses its ability to consume an outstanding release. Every change to
|
||||
//! row identity/order/action must have a fresh publication Id. Pass the pair
|
||||
//! (publication generation, drawer visit): a new visit resets armed controls even
|
||||
//! if Hide and Show coalesce without an intermediate hidden Tree. The child tree
|
||||
//! resets when either component changes, clearing button presses and row focus;
|
||||
//! the outer scroller and editor trees are not reset.
|
||||
//!
|
||||
//! Capturing stops Iced event bubbling and later consumers that respect capture.
|
||||
//! It cannot undo an earlier sibling's event handling. This is a row-list guard,
|
||||
//! not a root event filter or a guard for actions in child overlays. Diagnostic
|
||||
//! messages still need publication/snapshot and drawer-visit validation at dispatch.
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, mouse, touch};
|
||||
use oa_core::Id;
|
||||
|
||||
pub struct Guard<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
|
||||
identity: Option<(Id, Id)>,
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Guard<'a, Message, Theme, Renderer> {
|
||||
pub fn new(
|
||||
identity: Option<(Id, Id)>,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
identity,
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Pointer {
|
||||
fn press(event: &Event) -> Option<Self> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => Some(Self::Mouse),
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => Some(Self::Touch(*id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn release(event: &Event) -> Option<Self> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => Some(Self::Mouse),
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) => Some(Self::Touch(*id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
pressed: Option<Pointer>,
|
||||
swallowed: Vec<Pointer>,
|
||||
}
|
||||
impl State {
|
||||
fn quarantine(&mut self, pointer: Pointer) {
|
||||
if !self.swallowed.contains(&pointer) {
|
||||
self.swallowed.push(pointer);
|
||||
}
|
||||
}
|
||||
fn forget(&mut self, pointer: Pointer) -> bool {
|
||||
let found = self.swallowed.contains(&pointer);
|
||||
self.swallowed.retain(|item| *item != pointer);
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
|
||||
for Guard<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State {
|
||||
identity: self.identity,
|
||||
pressed: None,
|
||||
swallowed: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if state.identity != self.identity {
|
||||
state.identity = self.identity;
|
||||
if let Some(pointer) = state.pressed.take() {
|
||||
state.quarantine(pointer);
|
||||
}
|
||||
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 size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let release = Pointer::release(event);
|
||||
if let Some(pointer) = release {
|
||||
// Bounds may have become empty or moved. Consume the physical
|
||||
// gesture's terminal event regardless of its current position.
|
||||
if state.forget(pointer) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
// Iced Button has a single is_pressed bit, not a pointer identity.
|
||||
// A different finger/mouse release must not activate that button.
|
||||
if state.pressed.is_some_and(|pressed| pressed != pointer) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let press = Pointer::press(event);
|
||||
if let Some(pointer) = state.pressed.filter(|pointer| {
|
||||
press == Some(*pointer) || (release == Some(*pointer) && shell.is_event_captured())
|
||||
}) {
|
||||
// Recover an omitted terminal event, or cancel one intercepted by
|
||||
// an earlier consumer. An off-target mouse release / lost touch
|
||||
// clears Iced Button's pressed state without rebuilding this
|
||||
// publication's tree (which would also discard row focus).
|
||||
state.pressed = None;
|
||||
let cancel = match pointer {
|
||||
Pointer::Mouse => Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Pointer::Touch(id) => Event::Touch(touch::Event::FingerLost {
|
||||
id,
|
||||
position: iced::Point::ORIGIN,
|
||||
}),
|
||||
};
|
||||
let mut discarded = Vec::new();
|
||||
let mut cancellation = Shell::new(&mut discarded);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&cancel,
|
||||
layout,
|
||||
mouse::Cursor::Unavailable,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut cancellation,
|
||||
viewport,
|
||||
);
|
||||
// Cancellation must not publish an action or capture the fresh
|
||||
// down. Preserve rendering requests from the state change only.
|
||||
shell.request_redraw();
|
||||
if cancellation.is_layout_invalid() {
|
||||
shell.invalidate_layout();
|
||||
}
|
||||
if cancellation.are_widgets_invalid() {
|
||||
shell.invalidate_widgets();
|
||||
}
|
||||
}
|
||||
if shell.is_event_captured() || self.identity.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(pointer) = press {
|
||||
// A fresh down starts a new gesture even if the platform omitted a
|
||||
// previous release (for example while the window lacked focus).
|
||||
state.forget(pointer);
|
||||
if let Some(pressed) = state.pressed {
|
||||
if pressed != pointer {
|
||||
state.quarantine(pointer);
|
||||
}
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let was_pressed = release.is_some() && release == state.pressed;
|
||||
if was_pressed {
|
||||
state.pressed = None;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
if let Some(pointer) = press
|
||||
&& shell.is_event_captured()
|
||||
{
|
||||
state.pressed = Some(pointer);
|
||||
}
|
||||
if was_pressed {
|
||||
// FingerLost cancels Iced Button state but does not itself capture.
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut widget::Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>
|
||||
From<Guard<'a, Message, Theme, Renderer>> for Element<'a, Message, Theme, Renderer>
|
||||
{
|
||||
fn from(guard: Guard<'a, Message, Theme, Renderer>) -> Self {
|
||||
Element::new(guard)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,626 +0,0 @@
|
||||
use super::*;
|
||||
use iced::advanced::widget::operation::{self, Focusable, Scrollable};
|
||||
use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input};
|
||||
use iced::{Point, Theme, event, keyboard};
|
||||
|
||||
type View = Element<'static, Message, Theme, ()>;
|
||||
const BOUNDS: Rectangle = Rectangle {
|
||||
x: 0.,
|
||||
y: 0.,
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Primary(u8),
|
||||
Related(u8),
|
||||
BackgroundButton,
|
||||
BackgroundRelease,
|
||||
Submit(u8),
|
||||
Edit,
|
||||
}
|
||||
|
||||
fn rows(version: u8) -> View {
|
||||
column![
|
||||
button(Space::new())
|
||||
.width(200.)
|
||||
.height(40.)
|
||||
.padding(0)
|
||||
.on_press(Message::Primary(version)),
|
||||
button(Space::new())
|
||||
.width(200.)
|
||||
.height(40.)
|
||||
.padding(0)
|
||||
.on_press(Message::Related(version)),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn guarded(generation: Option<Id>, version: u8) -> View {
|
||||
let child: View = if generation.is_some() {
|
||||
rows(version)
|
||||
} else {
|
||||
Space::new().into()
|
||||
};
|
||||
Guard::new(
|
||||
generation.map(|publication| (publication, Id::nil())),
|
||||
child,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
fn press() -> Event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
}
|
||||
fn release() -> Event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
}
|
||||
fn enter() -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Enter),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn touch_down(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerPressed {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
fn touch_up(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerLifted {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
fn touch_lost(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerLost {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
view: View,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(mut view: View) -> Self {
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
|
||||
);
|
||||
Self { view, tree, node }
|
||||
}
|
||||
fn replace(&mut self, mut view: View) {
|
||||
self.tree.diff(view.as_widget());
|
||||
self.node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
|
||||
);
|
||||
self.view = view;
|
||||
}
|
||||
fn event(&mut self, event: Event, point: Point) -> (Vec<Message>, event::Status) {
|
||||
self.event_with_capture(event, point, false)
|
||||
}
|
||||
fn event_with_capture(
|
||||
&mut self,
|
||||
event: Event,
|
||||
point: Point,
|
||||
captured: bool,
|
||||
) -> (Vec<Message>, event::Status) {
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if captured {
|
||||
shell.capture_event();
|
||||
}
|
||||
self.view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Available(point),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&BOUNDS,
|
||||
);
|
||||
let status = shell.event_status();
|
||||
(messages, status)
|
||||
}
|
||||
fn operate(&mut self, operation: &mut dyn widget::Operation) {
|
||||
self.view
|
||||
.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&self.node), &(), operation);
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.operate(&mut operation::focusable::focus::<()>("row".into()));
|
||||
}
|
||||
fn focused(&mut self) -> bool {
|
||||
#[derive(Default)]
|
||||
struct Focus(bool);
|
||||
impl widget::Operation for Focus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn Focusable,
|
||||
) {
|
||||
self.0 |= state.is_focused();
|
||||
}
|
||||
}
|
||||
let mut focus = Focus::default();
|
||||
self.operate(&mut focus);
|
||||
focus.0
|
||||
}
|
||||
fn scroll(&mut self) -> Vector {
|
||||
#[derive(Default)]
|
||||
struct Offset(Option<Vector>);
|
||||
impl widget::Operation for Offset {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn Scrollable,
|
||||
) {
|
||||
self.0 = Some(translation);
|
||||
}
|
||||
}
|
||||
let mut offset = Offset::default();
|
||||
self.operate(&mut offset);
|
||||
offset.0.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_primary_and_related_mouse_clicks_survive_same_publication_diff() {
|
||||
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
|
||||
let generation = Id::new_v4();
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(generation), 1));
|
||||
assert_eq!(ui.event(press(), point), (vec![], event::Status::Captured));
|
||||
ui.replace(guarded(Some(generation), 1));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_new_visit_cancels_armed_rows_without_a_hidden_tree_frame() {
|
||||
for touch in [false, true] {
|
||||
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
|
||||
let publication = Id::new_v4();
|
||||
let old_visit = Id::new_v4();
|
||||
let new_visit = Id::new_v4();
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(Guard::new(Some((publication, old_visit)), rows(1)).into());
|
||||
let down = || if touch { touch_down(7, point) } else { press() };
|
||||
let up = || if touch { touch_up(7, point) } else { release() };
|
||||
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
|
||||
// Hide→Show has already happened in application state. No None
|
||||
// publication or empty child is ever diffed into this retained Tree.
|
||||
ui.replace(Guard::new(Some((publication, new_visit)), rows(2)).into());
|
||||
assert_eq!(ui.event(up(), point), (vec![], event::Status::Captured));
|
||||
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
|
||||
assert_eq!(
|
||||
ui.event(up(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_iced_button_retargets_a_press_after_plain_tree_diff() {
|
||||
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(rows(1));
|
||||
assert!(ui.event(press(), point).0.is_empty());
|
||||
ui.replace(rows(2));
|
||||
assert_eq!(ui.event(release(), point).0, vec![expected]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_primary_and_related_rows_consume_stale_release_then_accept_new_click() {
|
||||
for y in [20., 60.] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
ui.replace(guarded(Some(Id::new_v4()), 3));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
ui.event(press(), point);
|
||||
let expected = if y < 40. {
|
||||
Message::Primary(3)
|
||||
} else {
|
||||
Message::Related(3)
|
||||
};
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_zero_size_list_consumes_release_outside_old_bounds_once() {
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), Point::new(20., 20.));
|
||||
ui.replace(guarded(None, 0));
|
||||
ui.replace(guarded(None, 0));
|
||||
assert_eq!(ui.node.size(), Size::ZERO);
|
||||
let outside = Point::new(350., 250.);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
assert_eq!(ui.event(release(), Point::new(20., 20.)).0, vec![]);
|
||||
}
|
||||
|
||||
fn with_background(content: View) -> View {
|
||||
mouse_area(
|
||||
button(container(content).width(250.).height(100.))
|
||||
.padding(0)
|
||||
.on_press(Message::BackgroundButton),
|
||||
)
|
||||
.on_release(Message::BackgroundRelease)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_release_does_not_bubble_to_real_background_controls() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(with_background(guarded(Some(Id::new_v4()), 1)));
|
||||
ui.event(press(), point);
|
||||
ui.replace(with_background(guarded(None, 0)));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
// The enclosing real button still supports a fresh ordinary click.
|
||||
ui.event(press(), point);
|
||||
assert_eq!(
|
||||
ui.event(release(), point).0,
|
||||
vec![Message::BackgroundButton]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_removing_unguarded_rows_exposes_background_release() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(with_background(rows(1)));
|
||||
ui.event(press(), point);
|
||||
ui.replace(with_background(Space::new().into()));
|
||||
assert_eq!(
|
||||
ui.event(release(), point).0,
|
||||
vec![Message::BackgroundRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_touch_activates_primary_and_related_and_cancel_does_not_activate() {
|
||||
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
assert_eq!(
|
||||
ui.event(touch_down(1, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_lost(2, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert!(ui.event(touch_up(2, point), point).0.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_touch_consumes_lift_or_cancel_even_when_hidden() {
|
||||
for hide in [false, true] {
|
||||
for lost in [false, true] {
|
||||
for y in [20., 60.] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.replace(guarded(if hide { None } else { Some(Id::new_v4()) }, 2));
|
||||
let end = if lost {
|
||||
touch_lost(1, point)
|
||||
} else {
|
||||
touch_up(1, point)
|
||||
};
|
||||
assert_eq!(ui.event(end, point), (vec![], event::Status::Captured));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_finger_or_mouse_release_cannot_complete_another_fingers_press() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(2, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point).0,
|
||||
vec![Message::Primary(1)]
|
||||
);
|
||||
}
|
||||
|
||||
fn keyboard_row(version: u8) -> View {
|
||||
// Iced 0.14.2 Button has no focus/keyboard activation. A real focusable
|
||||
// submit control tests the wrapper's focus lifecycle without inventing it.
|
||||
text_input("Diagnostic action", "row")
|
||||
.id("row")
|
||||
.on_input(|_| Message::Edit)
|
||||
.on_submit(Message::Submit(version))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_preserves_focus_but_replacement_never_inherits_activation() {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
|
||||
ui.focus();
|
||||
assert!(ui.focused());
|
||||
ui.replace(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
|
||||
assert!(ui.focused());
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
|
||||
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(2)).into());
|
||||
assert!(!ui.focused());
|
||||
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
|
||||
ui.focus();
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
|
||||
ui.replace(Guard::new(None, Space::new()).into());
|
||||
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(3)).into());
|
||||
assert!(!ui.focused());
|
||||
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_plain_focusable_row_migrates_activation_to_replacement() {
|
||||
let mut ui = Mounted::new(keyboard_row(1));
|
||||
ui.focus();
|
||||
ui.replace(keyboard_row(2));
|
||||
assert!(ui.focused());
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_scroll_offset_survives_same_and_changed_publications() {
|
||||
fn pane(generation: Id) -> View {
|
||||
scrollable(Guard::new(
|
||||
Some((generation, Id::nil())),
|
||||
column![rows(1), Space::new().height(600.)],
|
||||
))
|
||||
.width(240.)
|
||||
.height(100.)
|
||||
.into()
|
||||
}
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(pane(generation));
|
||||
ui.event(
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
||||
}),
|
||||
Point::new(20., 20.),
|
||||
);
|
||||
let offset = ui.scroll();
|
||||
assert_eq!(offset, Vector::new(0., 120.));
|
||||
ui.replace(pane(generation));
|
||||
assert_eq!(ui.scroll(), offset);
|
||||
ui.replace(pane(Id::new_v4()));
|
||||
assert_eq!(ui.scroll(), offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_touch_release_does_not_disarm_a_new_current_gesture() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(2, point), point).0,
|
||||
vec![Message::Primary(2)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_mouse_press_after_missing_stale_release_can_activate_current_row() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
// The platform delivers another down without the previous up.
|
||||
ui.event(press(), point);
|
||||
assert_eq!(ui.event(release(), point).0, vec![Message::Primary(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missed_press_is_ignored_and_dragging_release_outside_does_not_activate() {
|
||||
let inside = Point::new(20., 20.);
|
||||
let outside = Point::new(350., 250.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
assert_eq!(ui.event(press(), outside), (vec![], event::Status::Ignored));
|
||||
assert_eq!(
|
||||
ui.event(release(), inside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.event(press(), inside);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), inside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_missing_terminal_recovers_on_fresh_mouse_or_touch_down() {
|
||||
let primary = Point::new(20., 20.);
|
||||
let related = Point::new(20., 60.);
|
||||
for is_touch in [false, true] {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(guarded(Some(generation), 1));
|
||||
let down = |point| {
|
||||
if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
}
|
||||
};
|
||||
let up = |point| {
|
||||
if is_touch {
|
||||
touch_up(1, point)
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
};
|
||||
ui.event(down(primary), primary);
|
||||
ui.replace(guarded(Some(generation), 1));
|
||||
// The first gesture never receives its terminal event; same Id means
|
||||
// no publication reset can accidentally make this regression pass.
|
||||
assert_eq!(
|
||||
ui.event(down(related), related),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
|
||||
assert!(ui.event(up(primary), primary).0.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_captured_terminal_cancels_child_without_activation_or_lingering_press() {
|
||||
let primary = Point::new(20., 20.);
|
||||
let related = Point::new(20., 60.);
|
||||
for is_touch in [false, true] {
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
let down = |point| {
|
||||
if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
}
|
||||
};
|
||||
let up = |point| {
|
||||
if is_touch {
|
||||
touch_up(1, point)
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
};
|
||||
ui.event(down(primary), primary);
|
||||
assert_eq!(
|
||||
ui.event_with_capture(up(primary), primary, true),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
// An unpaired second release must not expose a lingering button press.
|
||||
assert_eq!(
|
||||
ui.event(up(primary), primary),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.event(down(related), related);
|
||||
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_terminal_cancellation_preserves_same_publication_focusable_child_state() {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(
|
||||
Guard::new(
|
||||
Some((generation, Id::nil())),
|
||||
column![rows(1), keyboard_row(1)],
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
ui.event(press(), Point::new(20., 20.));
|
||||
// Focus through an operation avoids making the old pointer gesture whole.
|
||||
ui.focus();
|
||||
assert!(ui.focused());
|
||||
assert!(
|
||||
ui.event_with_capture(release(), Point::new(20., 20.), true)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
ui.focused(),
|
||||
"gesture cancellation must not remount the row tree"
|
||||
);
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_zero_height_scroller_delivers_cancelled_mouse_and_touch_terminals_to_guard() {
|
||||
fn pane(generation: Option<Id>) -> View {
|
||||
scrollable(guarded(generation, 1))
|
||||
.width(240.)
|
||||
.height(if generation.is_some() { 100. } else { 0. })
|
||||
.into()
|
||||
}
|
||||
let point = Point::new(20., 20.);
|
||||
let outside = Point::new(350., 250.);
|
||||
for is_touch in [false, true] {
|
||||
let mut ui = Mounted::new(pane(Some(Id::new_v4())));
|
||||
let down = if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
};
|
||||
assert_eq!(ui.event(down, point), (vec![], event::Status::Captured));
|
||||
ui.replace(pane(None));
|
||||
ui.replace(pane(None));
|
||||
assert_eq!(ui.node.size().height, 0.);
|
||||
let up = if is_touch {
|
||||
touch_up(1, outside)
|
||||
} else {
|
||||
release()
|
||||
};
|
||||
assert_eq!(ui.event(up, outside), (vec![], event::Status::Captured));
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,6 @@ use std::{fmt, sync::Arc};
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[path = "document_alias_rename.rs"]
|
||||
mod alias_rename;
|
||||
pub use alias_rename::PreparedAliasRename;
|
||||
|
||||
struct HistoryEntry {
|
||||
snapshot: Arc<CompiledSourceProject>,
|
||||
bytes: usize,
|
||||
@@ -95,13 +91,11 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
// Distinguishes equal accepted snapshots belonging to different documents.
|
||||
alias_owner: Arc<()>,
|
||||
current: Arc<CompiledSourceProject>,
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
current_bytes: usize,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
saved: Arc<SourceBundle>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
@@ -127,12 +121,11 @@ impl Document {
|
||||
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
|
||||
let current_bytes = estimated_bytes(¤t);
|
||||
Ok(Self {
|
||||
alias_owner: Arc::new(()),
|
||||
current,
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
saved: Some(Arc::new(bundle)),
|
||||
saved: Arc::new(bundle),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
@@ -163,7 +156,7 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
||||
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
|
||||
}
|
||||
|
||||
/// Recovered work is a detached copy until explicitly saved by its user.
|
||||
@@ -188,24 +181,10 @@ impl Document {
|
||||
/// Records the bytes actually saved, even when later edits remain current.
|
||||
/// The I/O adapter owns document identity and save completion ordering.
|
||||
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
||||
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
||||
self.saved = Arc::new(snapshot.bundle.clone());
|
||||
self.recovered_unsaved = false;
|
||||
}
|
||||
|
||||
/// Bind an installed or recovered repair destination to source read there.
|
||||
/// These bytes may be invalid or differ from the retained accepted model,
|
||||
/// but must replace the previous disk baseline. Accepted history is retained;
|
||||
/// applying or undoing to old saved content does not save this destination.
|
||||
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
||||
self.saved = Some(Arc::new(source));
|
||||
}
|
||||
|
||||
/// Recovery changed the current destination, but its source could not be
|
||||
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
||||
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
||||
self.saved = None;
|
||||
}
|
||||
|
||||
/// Check a source Save as before writing any files. A retained included file
|
||||
/// must never be displaced by the new entry, including on a later Undo.
|
||||
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
||||
@@ -401,17 +380,8 @@ impl Document {
|
||||
return false;
|
||||
}
|
||||
let bytes = estimated_bytes(&compiled);
|
||||
self.commit_snapshot(Arc::new(compiled), bytes)
|
||||
}
|
||||
|
||||
// Only validated internal paths supply a changed snapshot and its exact
|
||||
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
|
||||
// path checks its private changed flag and exact accepted-snapshot authority.
|
||||
// Alias preparation shares this algorithm while retaining the live save and
|
||||
// history state, and does not clone/recompile the candidate on adoption.
|
||||
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
|
||||
self.past.push(HistoryEntry {
|
||||
snapshot: std::mem::replace(&mut self.current, compiled),
|
||||
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
|
||||
bytes: std::mem::replace(&mut self.current_bytes, bytes),
|
||||
});
|
||||
self.future.clear();
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
|
||||
//! not authorize adoption; only this module can mint a prepared rename.
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A pinned intent minted by its owning Document. Move it to a worker and call
|
||||
/// `prepare`; preparation has no access to the live Document or its histories.
|
||||
#[derive(Debug)]
|
||||
pub struct AliasRenameRequest {
|
||||
owner: Arc<()>,
|
||||
before: Arc<CompiledSourceProject>,
|
||||
revision: u64,
|
||||
limits: ProjectLimits,
|
||||
target: Id,
|
||||
old_alias: String,
|
||||
new_alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedData {
|
||||
request: AliasRenameRequest,
|
||||
after: Arc<CompiledSourceProject>,
|
||||
before_bytes: usize,
|
||||
after_bytes: usize,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Immutable, Clone-compatible transport for preview and approval. Neither
|
||||
/// mutable candidate access nor deserialization can manufacture this authority.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedAliasRename(Arc<PreparedData>);
|
||||
|
||||
impl PreparedAliasRename {
|
||||
pub fn before(&self) -> &CompiledSourceProject {
|
||||
&self.0.request.before
|
||||
}
|
||||
|
||||
pub fn after(&self) -> &CompiledSourceProject {
|
||||
&self.0.after
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Id {
|
||||
self.0.request.target
|
||||
}
|
||||
|
||||
pub fn old_alias(&self) -> &str {
|
||||
&self.0.request.old_alias
|
||||
}
|
||||
|
||||
pub fn new_alias(&self) -> &str {
|
||||
&self.0.request.new_alias
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.0.request.revision
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.0.changed
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(message: &str) -> DocumentError {
|
||||
ModelError(message.into()).into()
|
||||
}
|
||||
|
||||
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
|
||||
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn alias_rename_request(
|
||||
&self,
|
||||
target: Id,
|
||||
replacement: String,
|
||||
) -> Result<AliasRenameRequest, DocumentError> {
|
||||
if self.revision == u64::MAX {
|
||||
return Err(refusal(
|
||||
"The document revision is exhausted. Reopen before renaming.",
|
||||
));
|
||||
}
|
||||
let old_alias = self
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?;
|
||||
// A candidate has a nonzero estimate. Avoid expensive preparation when
|
||||
// even the current snapshot leaves no room for its immediate Undo.
|
||||
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(AliasRenameRequest {
|
||||
owner: self.alias_owner.clone(),
|
||||
before: self.current.clone(),
|
||||
revision: self.revision,
|
||||
limits: self.limits,
|
||||
target,
|
||||
old_alias,
|
||||
new_alias: replacement,
|
||||
})
|
||||
}
|
||||
|
||||
/// App must separately consume its current approval and check workspace,
|
||||
/// invocation and draft freshness. This checks accepted Document authority.
|
||||
pub fn commit_alias_rename(
|
||||
&mut self,
|
||||
prepared: &PreparedAliasRename,
|
||||
) -> Result<bool, DocumentError> {
|
||||
let data = &prepared.0;
|
||||
let request = &data.request;
|
||||
if self.revision == u64::MAX
|
||||
|| self.revision != request.revision
|
||||
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|
||||
|| !Arc::ptr_eq(&self.current, &request.before)
|
||||
{
|
||||
return Err(refusal(
|
||||
"This rename preview is stale. Preview the current alias again.",
|
||||
));
|
||||
}
|
||||
if !data.changed {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.current_bytes != data.before_bytes {
|
||||
return Err(refusal(
|
||||
"The rename history estimate is stale. Preview again.",
|
||||
));
|
||||
}
|
||||
check_history_capacity(self.current_bytes, data.after_bytes)?;
|
||||
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl AliasRenameRequest {
|
||||
/// Run on the preparation worker. The typed command and source updater are
|
||||
/// the only candidate construction path, followed by alias-only invariants.
|
||||
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
|
||||
let before_project = &self.before.decoded.project;
|
||||
let candidate = prepare_command(
|
||||
before_project,
|
||||
&Command::SetAlias(self.target, self.new_alias.clone()),
|
||||
)?;
|
||||
let mut expected = before_project.clone();
|
||||
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
|
||||
let identities: BTreeMap<_, _> = before_project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
if candidate.project != expected || candidate.identity_map != identities {
|
||||
return Err(refusal(
|
||||
"Alias preparation changed unrelated model data or identities.",
|
||||
));
|
||||
}
|
||||
let after = source_project::update_bundle_with_identity_map(
|
||||
&self.before,
|
||||
&candidate.project,
|
||||
&candidate.identity_map,
|
||||
self.limits,
|
||||
)?;
|
||||
let before = &self.before;
|
||||
if after.decoded.project != expected
|
||||
|| before.bundle.entry != after.bundle.entry
|
||||
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|
||||
|| before.index.lexical_owners != after.index.lexical_owners
|
||||
|| !before
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path))
|
||||
.eq(after
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path)))
|
||||
{
|
||||
return Err(refusal(
|
||||
"Alias preparation changed source membership or ownership.",
|
||||
));
|
||||
}
|
||||
let changed = before.bundle != after.bundle;
|
||||
let before_bytes = estimated_bytes(before);
|
||||
let after_bytes = estimated_bytes(&after);
|
||||
if changed {
|
||||
check_history_capacity(before_bytes, after_bytes)?;
|
||||
}
|
||||
Ok(PreparedAliasRename(Arc::new(PreparedData {
|
||||
request: self,
|
||||
after: Arc::new(after),
|
||||
before_bytes,
|
||||
after_bytes,
|
||||
changed,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// Independent full-model expectation: SetAlias may change exactly one header
|
||||
// field, across every supported identity family. Do not reuse command execution
|
||||
// to derive the expected result, which would hide collateral command changes.
|
||||
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
|
||||
if project.id == id {
|
||||
project.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.elements.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.relations.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.diagrams.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.artifact_links.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.traces.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else {
|
||||
project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.find_map(|diagram| diagram.nodes.get_mut(&id))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?
|
||||
.alias = alias.to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "document_alias_rename_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,459 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> Document {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), r#"arcadia 7;
|
||||
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
|
||||
include "work.arcadia"; include "views.arcadia";
|
||||
}
|
||||
"#.replace('\n', "\r\n")),
|
||||
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
|
||||
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
|
||||
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
|
||||
assess; }
|
||||
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
|
||||
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
|
||||
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
|
||||
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
|
||||
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
|
||||
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no migration or ID insertion"
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
|
||||
document
|
||||
.alias_rename_request(target, replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Capture every live Document state relevant to refusal/no-op. Arc identity also
|
||||
// catches replacing an equal snapshot or saved marker behind the user's back.
|
||||
struct Unchanged {
|
||||
current: Arc<CompiledSourceProject>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
past: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
future: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
revision: u64,
|
||||
current_bytes: usize,
|
||||
recovered: bool,
|
||||
}
|
||||
|
||||
impl Unchanged {
|
||||
fn capture(document: &Document) -> Self {
|
||||
Self {
|
||||
current: document.snapshot(),
|
||||
saved: document.saved.clone(),
|
||||
past: document
|
||||
.past
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
future: document
|
||||
.future
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
revision: document.revision,
|
||||
current_bytes: document.current_bytes,
|
||||
recovered: document.recovered_unsaved,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert(&self, document: &Document) {
|
||||
assert!(Arc::ptr_eq(&self.current, &document.current));
|
||||
match (&self.saved, &document.saved) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("saved baseline changed"),
|
||||
}
|
||||
for (before, after) in [
|
||||
(&self.past, &document.past),
|
||||
(&self.future, &document.future),
|
||||
] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for ((snapshot, bytes), entry) in before.iter().zip(after) {
|
||||
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
|
||||
assert_eq!(*bytes, entry.bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(self.revision, document.revision);
|
||||
assert_eq!(self.current_bytes, document.current_bytes);
|
||||
assert_eq!(self.recovered, document.recovered_unsaved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
|
||||
// Literal role-specific replacements are independent of the updater/tokenizer.
|
||||
// They deliberately exclude prose, historical review text and URI spelling.
|
||||
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
|
||||
let cases: &[AliasCase<'_>] = &[
|
||||
(
|
||||
1,
|
||||
"mission",
|
||||
"renamed",
|
||||
&[("project mission ", "project renamed ")],
|
||||
),
|
||||
(
|
||||
2,
|
||||
"assess",
|
||||
"renamed",
|
||||
&[
|
||||
("oa_activity assess ", "oa_activity renamed "),
|
||||
("\r\n assess;", "\r\n renamed;"),
|
||||
("@assess;", "@renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
5,
|
||||
"transfer",
|
||||
"renamed",
|
||||
&[
|
||||
("interaction transfer ", "interaction renamed "),
|
||||
("\r\n transfer;", "\r\n renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
6,
|
||||
"review",
|
||||
"renamed",
|
||||
&[
|
||||
("trace review ", "trace renamed "),
|
||||
("owner @review;", "owner @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
7,
|
||||
"evidence",
|
||||
"renamed",
|
||||
&[("artifact evidence ", "artifact renamed ")],
|
||||
),
|
||||
(
|
||||
8,
|
||||
"overview",
|
||||
"renamed",
|
||||
&[
|
||||
("diagram overview ", "diagram renamed "),
|
||||
("diagram @overview;", "diagram @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
9,
|
||||
"placement",
|
||||
"renamed",
|
||||
&[("node placement ", "node renamed ")],
|
||||
),
|
||||
];
|
||||
for &(number, old, new, replacements) in cases {
|
||||
let mut document = fixture();
|
||||
let original = document.snapshot();
|
||||
let mut expected_model = document.project().clone();
|
||||
// Explicit per-family oracle, separate from production expectation helper.
|
||||
match number {
|
||||
1 => expected_model.alias = new.into(),
|
||||
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
|
||||
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
|
||||
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
|
||||
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
|
||||
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
|
||||
9 => {
|
||||
expected_model
|
||||
.diagrams
|
||||
.get_mut(&id(8))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(9))
|
||||
.unwrap()
|
||||
.alias = new.into()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut expected_bundle = document.bundle().clone();
|
||||
for source in expected_bundle.files.values_mut() {
|
||||
for &(before, after) in replacements {
|
||||
*source = source.replace(before, after);
|
||||
}
|
||||
}
|
||||
let state = Unchanged::capture(&document);
|
||||
let prepared = prepare(&document, id(number), new);
|
||||
state.assert(&document);
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.old_alias(), old);
|
||||
assert_eq!(prepared.new_alias(), new);
|
||||
assert_eq!(prepared.target(), id(number));
|
||||
assert_eq!(prepared.revision(), 0);
|
||||
assert_eq!(prepared.before().bundle, original.bundle);
|
||||
assert_eq!(prepared.after().bundle, expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, expected_model);
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert!(
|
||||
Arc::ptr_eq(&document.current, &prepared.0.after),
|
||||
"adopt the exact prepared Arc"
|
||||
);
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.dirty());
|
||||
let after = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
|
||||
after.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &original));
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &expected_bundle);
|
||||
assert_eq!(document.project(), &expected_model);
|
||||
document.mark_saved(document.snapshot());
|
||||
let reopened =
|
||||
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected_model);
|
||||
assert_eq!(reopened.bundle(), &expected_bundle);
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
|
||||
assert!(matches!(
|
||||
language::tokenize("oa_activity")[0].class,
|
||||
language::TokenClass::Keyword
|
||||
));
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "oa_activity");
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
|
||||
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
|
||||
let mut document = fixture();
|
||||
let first = prepare(&document, id(2), "changed");
|
||||
document.commit_alias_rename(&first).unwrap();
|
||||
assert!(document.undo());
|
||||
document.invalidate_saved_baseline();
|
||||
document.mark_recovered();
|
||||
let state = Unchanged::capture(&document);
|
||||
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
state.assert(&document);
|
||||
}
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(Id::nil(), "new".into())
|
||||
.is_err()
|
||||
);
|
||||
let no_op = prepare(&document, id(2), "assess");
|
||||
assert!(!no_op.changed());
|
||||
assert!(!document.commit_alias_rename(&no_op).unwrap());
|
||||
state.assert(&document);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
|
||||
let mut document = fixture();
|
||||
let old = prepare(&document, id(2), "renamed");
|
||||
let mut second = fixture();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
// Even an identical shared accepted Arc cannot confer this Document's owner.
|
||||
second.current = document.current.clone();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Later name".into()))
|
||||
.unwrap();
|
||||
let edited = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
edited.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
|
||||
let undone = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
undone.assert(&document);
|
||||
document.revision = u64::MAX - 1;
|
||||
let last = prepare(&document, id(2), "renamed");
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Exhaust revision".into()))
|
||||
.unwrap();
|
||||
document.undo();
|
||||
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
|
||||
let exhausted = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&last).is_err());
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_err()
|
||||
);
|
||||
exhausted.assert(&document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_retains_latest_saved_baseline_and_recovery_state() {
|
||||
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let original = document.snapshot();
|
||||
match baseline {
|
||||
"saved_before" => document.mark_saved(original.clone()),
|
||||
"unknown" => document.invalidate_saved_baseline(),
|
||||
"raw_repair" => {
|
||||
let mut raw = document.bundle().clone();
|
||||
raw.files
|
||||
.insert("project.arcadia".into(), "invalid repair source".into());
|
||||
document.bind_repair_baseline(raw);
|
||||
}
|
||||
"recovered" => document.mark_recovered(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let saved = document.saved.clone();
|
||||
let recovered = document.recovered_unsaved;
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert!(document.dirty());
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert_eq!(document.dirty(), baseline != "saved_before");
|
||||
}
|
||||
// A completed save after Preview remains authoritative, including when it
|
||||
// clears recovery; a worker must not restore its earlier state on Confirm.
|
||||
let mut document = fixture();
|
||||
document.mark_recovered();
|
||||
document.invalidate_saved_baseline();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
document.mark_saved(document.snapshot());
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert!(!document.recovered_unsaved);
|
||||
document.undo();
|
||||
assert!(!document.dirty());
|
||||
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
|
||||
document.redo();
|
||||
document.mark_saved(document.snapshot());
|
||||
document.undo();
|
||||
let return_to_saved = prepare(&document, id(2), "renamed");
|
||||
document.commit_alias_rename(&return_to_saved).unwrap();
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let exact = prepared.after().clone();
|
||||
let mut counterfeit = prepared.after().clone();
|
||||
counterfeit.decoded.project.name = "Injected unrelated edit".into();
|
||||
counterfeit.index.declarations.clear();
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(document.apply_compiled(counterfeit).is_err());
|
||||
unchanged.assert(&document);
|
||||
let mut unrelated = exact.decoded.project.clone();
|
||||
unrelated.name = "A self-consistent unrelated candidate".into();
|
||||
let forged_dto = source_project::compile_bundle(
|
||||
&source_project::format_bundle(&unrelated),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
|
||||
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
|
||||
// leaves the opaque capability and its exact adopted model/source intact.
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project(), &exact.decoded.project);
|
||||
assert_eq!(document.bundle(), &exact.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
|
||||
let mut document = fixture();
|
||||
let mut bundle = document.bundle().clone();
|
||||
let padding = HISTORY_BYTES / 2 - document.current_bytes;
|
||||
bundle
|
||||
.files
|
||||
.get_mut("views.arcadia")
|
||||
.unwrap()
|
||||
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
|
||||
document.apply_bundle(bundle).unwrap();
|
||||
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
|
||||
// A same-length alias retains the exact estimate; older history is evictable.
|
||||
let boundary = prepare(&document, id(2), "rating");
|
||||
assert_eq!(
|
||||
boundary.0.before_bytes + boundary.0.after_bytes,
|
||||
HISTORY_BYTES
|
||||
);
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "longer_alias".into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
document.commit_alias_rename(&boundary).unwrap();
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
|
||||
assert!(check_history_capacity(usize::MAX, 1).is_err());
|
||||
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
// Inject cache corruption to falsify the commit-side check independently of
|
||||
// the preparation admission. Normal edits also change revision and Arc.
|
||||
document.current_bytes = HISTORY_BYTES;
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(document.commit_alias_rename(&prepared).is_err());
|
||||
// No-op intent needs no Undo reservation even at the admission boundary.
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_ok()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
}
|
||||
@@ -120,9 +120,7 @@ fn seed(schema: &'static Value, depth: usize) -> Result<Value, &'static str> {
|
||||
if let Some(options) = alternatives(schema) {
|
||||
if options
|
||||
.iter()
|
||||
.filter_map(|s| resolve(s).ok())
|
||||
.filter_map(|s| s.get("enum").and_then(Value::as_array))
|
||||
.any(|values| values.contains(&json!("NotApplicable")))
|
||||
.any(|s| matches_value(s, &json!("NotApplicable"), 0))
|
||||
{
|
||||
return Ok(json!("NotApplicable"));
|
||||
}
|
||||
@@ -579,12 +577,12 @@ fn render<'a>(
|
||||
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
let item_path = format!("{path}/{index}");
|
||||
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
|
||||
if path == FACETS
|
||||
&& let Some(current) = current_observation(draft, project, &item_path)
|
||||
{
|
||||
if path == FACETS {
|
||||
if let Some(current) = current_observation(draft, project, &item_path) {
|
||||
card = card.push(button("Use current counterpart observations")
|
||||
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
|
||||
}
|
||||
}
|
||||
card = card.push(row![
|
||||
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
|
||||
button("↓").on_press_maybe((index + 1 < items.len()).then(|| Message::FieldArray(path.into(), Some(index), 1))),
|
||||
|
||||
@@ -752,38 +752,6 @@ fn choose_history_constructor(draft: &mut Draft, path: &str, label: &str) {
|
||||
draft.shape(&path, value).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_optional_uuid_and_text_seed_absence_without_inventing_observations() {
|
||||
for fact in ["Header/Parent", "Artifact/review/Revision"] {
|
||||
let side = format!("{FACETS}/0/{fact}/oa");
|
||||
let schema = resolve(schema_at(&side).unwrap()).unwrap();
|
||||
assert_eq!(seed(schema, 0).unwrap(), json!("NotApplicable"));
|
||||
let observed_value = alternatives(schema)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|branch| branch_label(branch) == "Observed value")
|
||||
.unwrap();
|
||||
let value = branch_seed(observed_value).unwrap();
|
||||
assert_eq!(value, json!({"Value": null}));
|
||||
assert_eq!(
|
||||
seed(schema_at(&format!("{side}/Value")).unwrap(), 0).unwrap(),
|
||||
Value::Null,
|
||||
"a plain text/UUID shape is not the NotApplicable enum constructor"
|
||||
);
|
||||
if fact == "Header/Parent" {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Observed<Option<Id>>>(value).unwrap(),
|
||||
Observed::Value(None)
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Observed<Option<String>>>(value).unwrap(),
|
||||
Observed::Value(None)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_nested_optional_number_events_switch_absent_present_and_recover_invalid_raw() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
|
||||
@@ -187,8 +187,8 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
|
||||
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
|
||||
"Expand the diagram's side palette to see tool names. Choose Entity, click its position in the diagram, enter Rescue coordinator and press Enter. Escape cancels the draft. Use Actor for an external participant such as a Vessel in distress. Right-clicking a blank area also offers Create Entity here.",
|
||||
"Right-click Rescue coordinator and choose Add activity. Enter Assess incident and press Enter to create the activity and its performer allocation together. Ordinary Activity creation in the palette leaves work unallocated; use + Relationship with Allocation to assign existing work. The allocated-work view shows work inside its performer.",
|
||||
"Choose Entity in the diagram's Create toolbar. Enter Rescue coordinator, choose Choose position, then click the diagram. Use Actor for an external participant such as a Vessel in distress. You can also right-click a blank area, choose Create Entity here, enter its name and choose Create here.",
|
||||
"Choose Activity in the same toolbar, name it Assess incident and place it. Select Rescue coordinator and use + Relationship with Allocation to assign Assess incident to it. The allocated-work view then shows the activity inside its performer.",
|
||||
"Use Add existing to place an element that is already in the model. The initial choices match this view; no search is required. Include already shown offers Locate for elements already placed here.",
|
||||
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
|
||||
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
|
||||
@@ -262,8 +262,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Create and allocate work",
|
||||
&[
|
||||
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
|
||||
"In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
|
||||
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. To assign existing leaf work, use + Relationship with Allocation from the participant to the work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
|
||||
"In the diagram's Create toolbar, choose Activity in OA or Function in SA, enter a name and click to place it. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
|
||||
"To assign work to a participant, use + Relationship with Allocation from the participant to the leaf work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
|
||||
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
|
||||
],
|
||||
),
|
||||
@@ -445,9 +445,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
|
||||
"Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
|
||||
"Select that behavior in the page's selector. Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
@@ -489,8 +489,8 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Create a scenario",
|
||||
&[
|
||||
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
|
||||
"Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
|
||||
"Open Behavior & scenarios, choose Scenario under Create element and add it at a legal root/owner. Select the scenario in the behavior selector.",
|
||||
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
|
||||
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
|
||||
"Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
|
||||
@@ -543,9 +543,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Build a state machine",
|
||||
&[
|
||||
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.",
|
||||
"If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.",
|
||||
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
|
||||
"In Behavior & scenarios, create or select a State machine, then choose Create diagram. This places its existing regions and vertices in a saved view. + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
|
||||
@@ -785,7 +783,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename F2 preview diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
@@ -798,27 +796,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Follow definitions and lexical references",
|
||||
"Place the caret on a declaration alias or an @reference. Choose Definition (F12) to locate its declaration, or References (Shift+F12) to list lexical uses. Choose a row when a definition is ambiguous, draft or detached. Lexical uses exclude comments and strings; they do not establish typed relationships or resolve an ambiguous alias to one declaration. Back (Alt+Left) returns to the previous source location; if that draft changed, it restores the current cursor instead of an obsolete selection. These shortcuts work while the source editor has focus; the Text toolbar also offers the actions.",
|
||||
),
|
||||
explain(
|
||||
"Read the navigation scope",
|
||||
"Queries inspect bounded snapshots of currently loaded drafts, without applying them or loading missing included files. The panel identifies partial scope, draft evidence, detached or unverified files, and alias or ID collisions. Counts show total, displayed and omitted rows. A missing result in partial scope does not establish that the whole project has no match. If sources change, stale rows are disabled; run Definition or References again. Row locations use one-based lines and UTF-8 byte columns. Navigation preserves your source drafts and the accepted model.",
|
||||
),
|
||||
explain(
|
||||
"Names, aliases and IDs serve different purposes",
|
||||
"The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.",
|
||||
),
|
||||
walkthrough(
|
||||
"Rename a source alias",
|
||||
&[
|
||||
"Place the caret on a declaration alias or live @reference, then choose Rename alias or press F2 in the source editor. First finish or revert pending source, property and behavior drafts; close or reattach any extra loaded detached files. Accepted edits do not need to be saved first.",
|
||||
"Edit Source alias and choose Preview (Enter also previews). Pasting line breaks, tabs or other control characters is rejected without changing the field; choose Preview again after correcting the paste. Review the changed files, hunks and lines; long lines have additional segments. The engineering name and UUID stay unchanged. Comments and quoted prose keep their original spelling. This preview covers the rename; a later Save can include earlier accepted edits too.",
|
||||
"Choose Rename alias to accept the complete preview, or Cancel/Escape to leave everything unchanged. An invalid, unchanged, stale or oversized preview cannot be confirmed. After editing the replacement, choose Preview again. If the source context changes, close the form and invoke Rename again on the current alias.",
|
||||
"Use Undo rename immediately, or the model Undo/Redo controls, to restore the complete source/model snapshot. Ctrl+Z in Text still undoes source drafting. Save all and reopen use the normal project workflow; an unconfirmed preview is not saved.",
|
||||
],
|
||||
),
|
||||
example(
|
||||
"Readable references",
|
||||
"For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.",
|
||||
@@ -913,10 +894,6 @@ fn article(topic: Topic) -> Article {
|
||||
"A property draft is not yet the model",
|
||||
"Apply changes validates the candidate. When it is rejected, correct the retained draft or use Revert. The canvas continues to show accepted values. Navigation that needs to apply an invalid draft may remain on the current record so your work is not lost. You can read this guide without applying or discarding that draft.",
|
||||
),
|
||||
explain(
|
||||
"A new behavior is still a draft",
|
||||
"Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
|
||||
),
|
||||
explain(
|
||||
"Aliases and references",
|
||||
"Stable source aliases must follow the language's identifier rules and avoid reserved words. Use a simple descriptive identifier such as assess_incident. Spaces belong in the displayed Name, not the alias. Reference pickers offer compatible model records; creating the required target first is often the missing step.",
|
||||
|
||||
@@ -6,7 +6,6 @@ mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagnostic_input;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -24,9 +23,7 @@ mod port_workflow;
|
||||
mod recovery;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_annotations;
|
||||
mod source_navigation;
|
||||
mod source_rename;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
//! Read-only source-range admission and projection onto the editor's real layout.
|
||||
//! Byte ranges retain their diagnostic meaning; visual bands expand to clusters.
|
||||
mod widget;
|
||||
use iced::{Point, Rectangle, Size, Vector, advanced::graphics::text::cosmic_text};
|
||||
use oa_core::source_project::SourceDiagnostic;
|
||||
use std::{collections::BTreeMap, ops::Range, sync::Arc};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
pub(crate) use widget::{Decorations, Snapshot};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Part {
|
||||
diagnostic: usize,
|
||||
bytes: Range<usize>,
|
||||
}
|
||||
|
||||
/// Source-only index, reusable across pointer movement, scroll and reflow.
|
||||
/// A caller must separately establish publication/path/snapshot authority.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Index {
|
||||
source: Arc<str>,
|
||||
lines: Vec<Range<usize>>,
|
||||
parts: BTreeMap<usize, Vec<Part>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct Band {
|
||||
pub diagnostic: usize,
|
||||
pub bounds: Rectangle,
|
||||
pub underline: Rectangle,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn new(source: Arc<str>, path: &str, diagnostics: &[SourceDiagnostic]) -> Self {
|
||||
// This is the same paragraph splitter used by the actual editor engine,
|
||||
// including standalone CR and LFCR, not the diagnostic's LF-only label.
|
||||
let mut lines: Vec<_> = cosmic_text::LineIter::new(&source)
|
||||
.map(|(bytes, _)| bytes)
|
||||
.collect();
|
||||
if lines.last().is_none_or(|line| line.end < source.len()) {
|
||||
lines.push(source.len()..source.len());
|
||||
}
|
||||
let mut parts: BTreeMap<usize, Vec<Part>> = BTreeMap::new();
|
||||
for (ordinal, diagnostic) in diagnostics.iter().enumerate() {
|
||||
let span = diagnostic.diagnostic.span;
|
||||
if diagnostic.path != path
|
||||
|| span.start > span.end
|
||||
|| span.end > source.len()
|
||||
|| !source.is_char_boundary(span.start)
|
||||
|| !source.is_char_boundary(span.end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let first = lines.partition_point(|line| line.start <= span.start) - 1;
|
||||
let last_byte = if span.start == span.end {
|
||||
span.end
|
||||
} else {
|
||||
span.end - 1
|
||||
};
|
||||
let last = lines.partition_point(|line| line.start <= last_byte) - 1;
|
||||
for (line_index, line) in lines.iter().enumerate().take(last + 1).skip(first) {
|
||||
let start = span.start.max(line.start).min(line.end) - line.start;
|
||||
let end = span.end.min(line.end) - line.start;
|
||||
parts.entry(line_index).or_default().push(Part {
|
||||
diagnostic: ordinal,
|
||||
bytes: start..end,
|
||||
});
|
||||
}
|
||||
}
|
||||
Self {
|
||||
source,
|
||||
lines,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only actual visible runs are projected. The engine's iterator already
|
||||
/// applies scroll; the caller supplies the actual padded origin and clip.
|
||||
pub fn bands(&self, buffer: &cosmic_text::Buffer, origin: Point, clip: Rectangle) -> Vec<Band> {
|
||||
let mut bands = Vec::new();
|
||||
if self.parts.is_empty() || clip.width <= 0. || clip.height <= 0. {
|
||||
return bands;
|
||||
}
|
||||
let translation = Vector::new(origin.x, origin.y);
|
||||
let mut checked_line = None;
|
||||
let mut source_matches = false;
|
||||
for run in buffer.layout_runs() {
|
||||
let Some(parts) = self.parts.get(&run.line_i) else {
|
||||
continue;
|
||||
};
|
||||
let underline_y = origin.y + run.line_top + run.line_height - 2.;
|
||||
if underline_y >= clip.y + clip.height || underline_y + 1.5 <= clip.y {
|
||||
continue;
|
||||
}
|
||||
// Several visible wraps can share a very large paragraph. Validate
|
||||
// its exact bytes once in this projection, never in a stale cache.
|
||||
if checked_line != Some(run.line_i) {
|
||||
checked_line = Some(run.line_i);
|
||||
source_matches = self
|
||||
.lines
|
||||
.get(run.line_i)
|
||||
.and_then(|line| self.source.get(line.clone()))
|
||||
== Some(run.text);
|
||||
}
|
||||
if !source_matches {
|
||||
// A mismatched editor must never remap a diagnostic by text search.
|
||||
continue;
|
||||
}
|
||||
let (first_byte, last_byte) = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.fold((usize::MAX, 0), |(first, last), glyph| {
|
||||
(first.min(glyph.start), last.max(glyph.end))
|
||||
});
|
||||
for part in parts {
|
||||
// Bidirectional glyphs need not be ordered by source byte. Their
|
||||
// envelope safely rejects disjoint ranges before a glyph scan.
|
||||
// Empty ranges retain the separate logical gap/EOF rules.
|
||||
if !part.bytes.is_empty()
|
||||
&& (part.bytes.end <= first_byte || part.bytes.start >= last_byte)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut intervals: Vec<(f32, f32)> = if part.bytes.is_empty() {
|
||||
anchor(buffer, &run, part.bytes.start)
|
||||
.into_iter()
|
||||
.map(|x| (x, x + 4.))
|
||||
.collect()
|
||||
} else {
|
||||
run.glyphs
|
||||
.iter()
|
||||
.filter(|glyph| {
|
||||
glyph.start < part.bytes.end && part.bytes.start < glyph.end
|
||||
})
|
||||
.map(|glyph| {
|
||||
let left = glyph.x.min(glyph.x + glyph.w);
|
||||
let right = glyph.x.max(glyph.x + glyph.w);
|
||||
(left, right.max(left + 1.))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
intervals.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut merged: Vec<(f32, f32)> = Vec::new();
|
||||
for (left, right) in intervals {
|
||||
if let Some(previous) = merged.last_mut()
|
||||
&& left <= previous.1 + 0.01
|
||||
{
|
||||
previous.1 = previous.1.max(right);
|
||||
} else {
|
||||
merged.push((left, right));
|
||||
}
|
||||
}
|
||||
for (mut left, mut right) in merged {
|
||||
if part.bytes.is_empty() {
|
||||
// An insertion at the right edge remains visible while
|
||||
// still containing its true boundary, without borrowing a token.
|
||||
let max_x = clip.x + clip.width - origin.x;
|
||||
if left <= max_x && right > max_x {
|
||||
right = max_x;
|
||||
left = (right - 4.).max(0.);
|
||||
}
|
||||
}
|
||||
let bounds = Rectangle::new(
|
||||
Point::new(left, run.line_top),
|
||||
Size::new(right - left, run.line_height),
|
||||
) + translation;
|
||||
let underline = Rectangle {
|
||||
y: bounds.y + bounds.height - 2.,
|
||||
height: 1.5,
|
||||
..bounds
|
||||
};
|
||||
if let (Some(bounds), Some(underline)) =
|
||||
(bounds.intersection(&clip), underline.intersection(&clip))
|
||||
&& bounds.width > 0.
|
||||
&& bounds.height > 0.
|
||||
&& underline.width > 0.
|
||||
&& underline.height > 0.
|
||||
{
|
||||
bands.push(Band {
|
||||
diagnostic: part.diagnostic,
|
||||
bounds,
|
||||
underline,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bands
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer the following cluster at a wrap boundary. EOF uses the preceding
|
||||
/// cluster's trailing edge; an empty paragraph has a real origin insertion.
|
||||
fn anchor(
|
||||
buffer: &cosmic_text::Buffer,
|
||||
run: &cosmic_text::LayoutRun<'_>,
|
||||
byte: usize,
|
||||
) -> Option<f32> {
|
||||
if run.glyphs.is_empty() {
|
||||
return (byte == 0 && run.text.is_empty()).then_some(0.);
|
||||
}
|
||||
if let Some(glyph) = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.find(|glyph| glyph.start <= byte && byte < glyph.end)
|
||||
{
|
||||
// Match cosmic-text's cursor heuristic inside a shaped cluster: a
|
||||
// proportional grapheme advance, including ligatures. This describes
|
||||
// a caret affordance, not an exact font-provided ligature caret or ink.
|
||||
let cluster = run.text.get(glyph.start..glyph.end)?;
|
||||
let mut before = 0;
|
||||
let mut total = 0;
|
||||
for (offset, _) in cluster.grapheme_indices(true) {
|
||||
before += usize::from(glyph.start + offset < byte);
|
||||
total += 1;
|
||||
}
|
||||
let advance = glyph.w * before as f32 / total.max(1) as f32;
|
||||
return Some(if glyph.level.is_rtl() {
|
||||
glyph.x + glyph.w - advance
|
||||
} else {
|
||||
glyph.x + advance
|
||||
});
|
||||
}
|
||||
// Visual order need not be logical order, even between wrapped runs. First
|
||||
// find this run's best logical predecessor, not its largest end overall.
|
||||
let previous = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|glyph| glyph.end <= byte)
|
||||
.max_by_key(|glyph| (glyph.end, glyph.start))?;
|
||||
// Most unrelated runs fail here without scanning the paragraph's layout.
|
||||
// Only a genuinely omitted separator or EOF may use a predecessor edge.
|
||||
if !run
|
||||
.text
|
||||
.get(previous.end..byte)?
|
||||
.chars()
|
||||
.all(char::is_whitespace)
|
||||
|| (byte != run.text.len() && !run.text.get(byte..)?.chars().next()?.is_whitespace())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let layout = buffer.lines.get(run.line_i)?.layout_opt()?;
|
||||
let key = (previous.end, previous.start);
|
||||
let mut current_found = false;
|
||||
for line in layout {
|
||||
let current = std::ptr::eq(line.glyphs.as_slice(), run.glyphs);
|
||||
current_found |= current;
|
||||
for glyph in &line.glyphs {
|
||||
// A painted source position, including an exact next-run start,
|
||||
// belongs to that containing run, never to a gap fallback here.
|
||||
if glyph.start <= byte && byte < glyph.end {
|
||||
return None;
|
||||
}
|
||||
let candidate = (glyph.end, glyph.start);
|
||||
if glyph.end <= byte && candidate > key {
|
||||
return None;
|
||||
}
|
||||
// A cluster split across visual runs has no single run-local edge.
|
||||
// Suppress unavailable geometry instead of moving it to a neighbor.
|
||||
if !current && candidate == key {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current_found {
|
||||
return None;
|
||||
}
|
||||
cluster_trailing_edge(run.glyphs, previous)
|
||||
}
|
||||
|
||||
fn cluster_trailing_edge(
|
||||
glyphs: &[cosmic_text::LayoutGlyph],
|
||||
cluster: &cosmic_text::LayoutGlyph,
|
||||
) -> Option<f32> {
|
||||
// Multiple glyphs can share a source cluster. Their iterator order follows
|
||||
// paragraph layout, so its last entry need not be the logical trailing edge.
|
||||
let rtl = cluster.level.is_rtl();
|
||||
let mut edge = None;
|
||||
for glyph in glyphs
|
||||
.iter()
|
||||
.filter(|glyph| glyph.start == cluster.start && glyph.end == cluster.end)
|
||||
{
|
||||
if glyph.level.is_rtl() != rtl {
|
||||
return None;
|
||||
}
|
||||
let next = if rtl {
|
||||
glyph.x.min(glyph.x + glyph.w)
|
||||
} else {
|
||||
glyph.x.max(glyph.x + glyph.w)
|
||||
};
|
||||
edge = Some(edge.map_or(next, |value: f32| {
|
||||
if rtl {
|
||||
value.min(next)
|
||||
} else {
|
||||
value.max(next)
|
||||
}
|
||||
}));
|
||||
}
|
||||
edge
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layout_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod anchor_tests;
|
||||
#[cfg(test)]
|
||||
mod performance_tests;
|
||||
@@ -1,260 +0,0 @@
|
||||
//! Manual actual-font witnesses; missing supported glyphs fail fixture admission.
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::text::Editor,
|
||||
text::{
|
||||
Editor as _, Highlighter as _, LineHeight, Wrapping,
|
||||
highlighter::{Format, PlainText},
|
||||
},
|
||||
};
|
||||
use iced::{Font, Pixels};
|
||||
|
||||
fn shaped(source: &str, width: f32) -> Editor {
|
||||
let mut editor = Editor::with_text(source);
|
||||
let mut highlighter = PlainText::new(&());
|
||||
editor.update(
|
||||
Size::new(width, 1200.),
|
||||
Font::MONOSPACE,
|
||||
Pixels(16.),
|
||||
LineHeight::Absolute(Pixels(24.)),
|
||||
Wrapping::WordOrGlyph,
|
||||
&mut highlighter,
|
||||
);
|
||||
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
|
||||
editor
|
||||
}
|
||||
|
||||
fn trailing(cluster: &[&cosmic_text::LayoutGlyph]) -> f32 {
|
||||
let rtl = cluster[0].level.is_rtl();
|
||||
assert!(cluster.iter().all(|g| g.level.is_rtl() == rtl));
|
||||
if rtl {
|
||||
cluster
|
||||
.iter()
|
||||
.map(|g| g.x.min(g.x + g.w))
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
} else {
|
||||
cluster
|
||||
.iter()
|
||||
.map(|g| g.x.max(g.x + g.w))
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual supported-font cluster and bidi witnesses"]
|
||||
fn final_multi_glyph_cluster_anchor_uses_logical_trailing_advance_edge() {
|
||||
// Marks without precomposed forms exercise real multi-glyph shaping. Test
|
||||
// both paragraph directions and both final-cluster directions. No font is
|
||||
// assumed to support a given specimen; glyph_id=0 cannot admit a witness.
|
||||
let sources = [
|
||||
"abc q\u{301}\u{323}",
|
||||
"אבג q\u{301}\u{323}",
|
||||
"abc א\u{5b0}\u{5bc}",
|
||||
"אבג א\u{5b0}\u{5bc}",
|
||||
"abc ب\u{64e}\u{651}",
|
||||
"אבג ب\u{64e}\u{651}",
|
||||
"abc x\u{20dd}",
|
||||
"אבג x\u{20dd}",
|
||||
];
|
||||
let mut admitted = 0;
|
||||
let mut opposite_direction = 0;
|
||||
for source in sources {
|
||||
let editor = shaped(source, 700.);
|
||||
let runs: Vec<_> = editor.buffer().layout_runs().collect();
|
||||
assert_eq!(runs.len(), 1, "wide specimen unexpectedly wrapped");
|
||||
let run = &runs[0];
|
||||
let Some(start) = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.end == source.len())
|
||||
.map(|g| g.start)
|
||||
.max()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let cluster: Vec<_> = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.start == start && g.end == source.len())
|
||||
.collect();
|
||||
if cluster.len() < 2 || cluster.iter().any(|g| g.glyph_id == 0) {
|
||||
continue;
|
||||
}
|
||||
admitted += 1;
|
||||
opposite_direction += usize::from(cluster[0].level.is_rtl() != run.rtl);
|
||||
let expected = trailing(&cluster);
|
||||
let actual = anchor(editor.buffer(), run, source.len());
|
||||
eprintln!(
|
||||
"EOF witness {source:?}: paragraph_rtl={} cluster={cluster:?} expected={expected} actual={actual:?}",
|
||||
run.rtl
|
||||
);
|
||||
assert!(
|
||||
actual.is_some_and(|x| (x - expected).abs() < 0.01),
|
||||
"logical EOF must follow the whole cluster, including shared-range glyphs; source={source:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
admitted > 0,
|
||||
"fixture admission: current fonts produced no supported multi-glyph final cluster"
|
||||
);
|
||||
assert!(
|
||||
opposite_direction > 0,
|
||||
"fixture admission: no supported final cluster opposed its paragraph direction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual supported-font cluster and bidi witnesses"]
|
||||
fn mixed_direction_omitted_separator_has_one_logical_predecessor_anchor() {
|
||||
let sources = [
|
||||
"one אבג דהו two זחט three יכל four מנס five עפצ six קרש seven",
|
||||
"אבג one two דהו three four זחט five six יכל seven eight מנס nine",
|
||||
];
|
||||
let mut admitted = 0;
|
||||
let mut rtl_predecessors = 0;
|
||||
let mut failures = Vec::new();
|
||||
let mut old_mismatches = 0;
|
||||
let mut old_inside_mismatches = 0;
|
||||
for source in sources {
|
||||
for width in [72., 96., 120., 144., 180.] {
|
||||
let editor = shaped(source, width);
|
||||
let runs: Vec<_> = editor.buffer().layout_runs().collect();
|
||||
assert!(runs.len() > 1);
|
||||
assert_eq!(
|
||||
runs.len(),
|
||||
editor.buffer().lines[0].layout_opt().unwrap().len(),
|
||||
"negative control requires the complete unscrolled paragraph"
|
||||
);
|
||||
assert!(
|
||||
runs.iter()
|
||||
.any(|r| r.glyphs.iter().any(|g| g.level.is_rtl()))
|
||||
);
|
||||
assert!(
|
||||
runs.iter()
|
||||
.any(|r| r.glyphs.iter().any(|g| !g.level.is_rtl()))
|
||||
);
|
||||
for (byte, character) in source.char_indices().filter(|(_, c)| *c == ' ') {
|
||||
// Require a genuinely omitted separator, not a rendered space.
|
||||
if runs
|
||||
.iter()
|
||||
.any(|r| r.glyphs.iter().any(|g| g.start <= byte && byte < g.end))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(end) = runs
|
||||
.iter()
|
||||
.flat_map(|r| r.glyphs)
|
||||
.filter(|g| g.end <= byte)
|
||||
.map(|g| g.end)
|
||||
.max()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(next) = runs
|
||||
.iter()
|
||||
.flat_map(|r| r.glyphs)
|
||||
.filter(|g| g.start >= byte + character.len_utf8())
|
||||
.map(|g| g.start)
|
||||
.min()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !source[end..next].chars().all(char::is_whitespace) {
|
||||
continue;
|
||||
}
|
||||
let previous: Vec<_> = runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.glyphs.iter().any(|g| g.end == end))
|
||||
.collect();
|
||||
// A cluster broken across runs is a separate boundary problem;
|
||||
// it is not admitted as this omitted-separator witness.
|
||||
if previous.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let (owner, run) = previous[0];
|
||||
let start = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.end == end)
|
||||
.map(|g| g.start)
|
||||
.max()
|
||||
.unwrap();
|
||||
let cluster: Vec<_> = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.start == start && g.end == end)
|
||||
.collect();
|
||||
if cluster.iter().any(|g| g.glyph_id == 0) {
|
||||
continue;
|
||||
}
|
||||
admitted += 1;
|
||||
rtl_predecessors += usize::from(cluster[0].level.is_rtl());
|
||||
let expected = trailing(&cluster);
|
||||
let actual: Vec<_> = runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, r)| anchor(editor.buffer(), r, byte).map(|x| (i, x)))
|
||||
.collect();
|
||||
let matches_expected = |positions: &[(usize, f32)]| {
|
||||
positions.len() == 1
|
||||
&& positions[0].0 == owner
|
||||
&& (positions[0].1 - expected).abs() < 0.01
|
||||
};
|
||||
// Exact rejected fallback, for already-proven omitted bytes:
|
||||
// per-run maximum end and the next visual run's minimum start.
|
||||
let old: Vec<_> = runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, r)| {
|
||||
let last = r.glyphs.iter().max_by_key(|g| g.end)?;
|
||||
let next_start = runs
|
||||
.get(i + 1)
|
||||
.and_then(|next| next.glyphs.iter().map(|g| g.start).min());
|
||||
(byte >= last.end
|
||||
&& (next_start.is_some_and(|start| byte < start)
|
||||
|| (next_start.is_none() && byte == source.len())))
|
||||
.then_some((
|
||||
i,
|
||||
if last.level.is_rtl() {
|
||||
last.x
|
||||
} else {
|
||||
last.x + last.w
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let inside_clip = expected >= 0. && expected <= width;
|
||||
if !matches_expected(&old) {
|
||||
old_mismatches += 1;
|
||||
old_inside_mismatches += usize::from(inside_clip);
|
||||
}
|
||||
eprintln!(
|
||||
"gap witness width={width} source={source:?} byte={byte} owner={owner} expected={expected} inside_horizontal_clip={inside_clip} actual={actual:?} rejected_old={old:?} cluster={cluster:?}"
|
||||
);
|
||||
if !matches_expected(&actual) {
|
||||
failures.push(format!("width={width} source={source:?} byte={byte} expected=({owner},{expected}) inside={inside_clip} actual={actual:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
admitted > 0,
|
||||
"fixture admission: no omitted mixed-direction separator found"
|
||||
);
|
||||
assert!(
|
||||
rtl_predecessors > 0,
|
||||
"fixture admission: no omitted separator after a supported RTL cluster"
|
||||
);
|
||||
eprintln!(
|
||||
"gap summary admitted={admitted} rejected_old_mismatches={old_mismatches} rejected_old_inside_horizontal_clip={old_inside_mismatches}"
|
||||
);
|
||||
assert!(
|
||||
old_mismatches > 0,
|
||||
"actual-layout negative control must discriminate the old fallback"
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"mixed-bidi logical anchor failures: {failures:#?}"
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Real shaped-layout projection checks. No window/GPU or hover authority claim.
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::text::Editor,
|
||||
text::{
|
||||
Editor as _, Highlighter as _, LineHeight, Wrapping,
|
||||
highlighter::{Format, PlainText},
|
||||
},
|
||||
};
|
||||
use iced::{Font, Pixels};
|
||||
use oa_core::{
|
||||
language::{Diagnostic, Span},
|
||||
validation::Severity,
|
||||
};
|
||||
|
||||
const ORIGIN: Point = Point::new(37., 53.);
|
||||
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
|
||||
SourceDiagnostic {
|
||||
path: "a".into(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Error,
|
||||
code: "layout".into(),
|
||||
message: "fixture".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 999,
|
||||
column: 999,
|
||||
span: Span { start, end },
|
||||
},
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn editor(source: &str, width: f32, height: f32) -> Editor {
|
||||
let mut editor = Editor::with_text(source);
|
||||
let mut highlighter = PlainText::new(&());
|
||||
editor.update(
|
||||
Size::new(width, height),
|
||||
Font::MONOSPACE,
|
||||
Pixels(16.),
|
||||
LineHeight::Absolute(Pixels(24.)),
|
||||
Wrapping::WordOrGlyph,
|
||||
&mut highlighter,
|
||||
);
|
||||
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
|
||||
editor
|
||||
}
|
||||
fn index(source: &str, ranges: &[Range<usize>]) -> Index {
|
||||
Index::new(
|
||||
Arc::from(source),
|
||||
"a",
|
||||
&ranges
|
||||
.iter()
|
||||
.map(|r| diagnostic(r.start, r.end))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
fn clip(width: f32, height: f32) -> Rectangle {
|
||||
Rectangle::new(ORIGIN, Size::new(width, height))
|
||||
}
|
||||
fn covered(bands: &[Band], point: Point, ordinal: usize) -> bool {
|
||||
bands
|
||||
.iter()
|
||||
.any(|b| b.diagnostic == ordinal && b.bounds.contains(point))
|
||||
}
|
||||
fn center(run: &cosmic_text::LayoutRun<'_>, glyph: &cosmic_text::LayoutGlyph) -> Point {
|
||||
Point::new(
|
||||
ORIGIN.x + glyph.x + glyph.w / 2.,
|
||||
ORIGIN.y + run.line_top + run.line_height / 2.,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_alias_marks_only_second_actual_glyph_sequence_and_blank_space_misses() {
|
||||
let source = "alias alias";
|
||||
let editor = editor(source, 500., 120.);
|
||||
let bands = index(source, std::slice::from_ref(&(6..11))).bands(
|
||||
editor.buffer(),
|
||||
ORIGIN,
|
||||
clip(500., 120.),
|
||||
);
|
||||
let run = editor.buffer().layout_runs().next().unwrap();
|
||||
let mut selected = 0;
|
||||
let mut rejected = 0;
|
||||
for glyph in run.glyphs {
|
||||
let at = center(&run, glyph);
|
||||
if glyph.start >= 6 {
|
||||
assert!(covered(&bands, at, 0));
|
||||
selected += 1;
|
||||
}
|
||||
if glyph.end <= 5 {
|
||||
assert!(!covered(&bands, at, 0));
|
||||
rejected += 1;
|
||||
}
|
||||
}
|
||||
assert!(selected > 0 && rejected > 0);
|
||||
assert!(!covered(
|
||||
&bands,
|
||||
Point::new(ORIGIN.x + 450., ORIGIN.y + 12.),
|
||||
0
|
||||
));
|
||||
assert!(!covered(
|
||||
&bands,
|
||||
Point::new(ORIGIN.x + 12., ORIGIN.y + 100.),
|
||||
0
|
||||
));
|
||||
// Wrong-origin negative control: expected source glyphs are not at raw local positions.
|
||||
assert!(!covered(&bands, Point::new(0., 0.), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_scrolled_multiline_range_uses_actual_visible_runs_and_effective_clip() {
|
||||
let source = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\nsecond source line has more words\nthird source line\n";
|
||||
let mut editor = editor(source, 150., 110.);
|
||||
assert!(
|
||||
editor
|
||||
.buffer()
|
||||
.layout_runs()
|
||||
.filter(|r| r.line_i == 0)
|
||||
.count()
|
||||
>= 2
|
||||
);
|
||||
editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2 });
|
||||
let scroll = editor.buffer().scroll();
|
||||
assert!(scroll.line > 0 || scroll.vertical > 0.);
|
||||
let effective = Rectangle::new(
|
||||
Point::new(ORIGIN.x + 9., ORIGIN.y + 5.),
|
||||
Size::new(126., 91.),
|
||||
);
|
||||
let bands = index(source, std::slice::from_ref(&(0..source.len()))).bands(
|
||||
editor.buffer(),
|
||||
ORIGIN,
|
||||
effective,
|
||||
);
|
||||
assert!(!bands.is_empty());
|
||||
for b in &bands {
|
||||
assert_eq!(b.bounds.intersection(&effective), Some(b.bounds));
|
||||
assert_eq!(b.underline.intersection(&effective), Some(b.underline));
|
||||
}
|
||||
let mut checked = 0;
|
||||
for run in editor.buffer().layout_runs() {
|
||||
// Only rows whose actual underline is inside the clip are hoverable by policy.
|
||||
let underline_y = ORIGIN.y + run.line_top + run.line_height - 1.;
|
||||
if underline_y < effective.y || underline_y >= effective.y + effective.height {
|
||||
continue;
|
||||
}
|
||||
for glyph in run.glyphs {
|
||||
let at = center(&run, glyph);
|
||||
if effective.contains(at) {
|
||||
assert!(covered(&bands, at, 0), "visible glyph {glyph:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 0);
|
||||
assert_eq!(editor.buffer().scroll(), scroll);
|
||||
assert!(
|
||||
index(source, std::slice::from_ref(&(0..source.len())))
|
||||
.bands(
|
||||
editor.buffer(),
|
||||
ORIGIN,
|
||||
Rectangle::new(Point::new(-500., -500.), Size::new(10., 10.))
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_cjk_and_bidi_ranges_cover_actual_clusters_without_neighbor_aliases() {
|
||||
let source = "left e\u{301} 救援 abc אבג xyz right";
|
||||
let editor = editor(source, 700., 120.);
|
||||
let combining = source.find("e\u{301}").unwrap();
|
||||
let cjk = source.find('救').unwrap();
|
||||
let rtl = source.find('א').unwrap();
|
||||
let ranges = [
|
||||
combining + 1..combining + 3,
|
||||
cjk..cjk + "救援".len(),
|
||||
rtl..rtl + "אבג".len(),
|
||||
];
|
||||
let bands = index(source, &ranges).bands(editor.buffer(), ORIGIN, clip(700., 120.));
|
||||
let run = editor.buffer().layout_runs().next().unwrap();
|
||||
assert!(
|
||||
run.glyphs.iter().any(|g| g.level.is_rtl()),
|
||||
"fixture must shape RTL"
|
||||
);
|
||||
assert!(
|
||||
run.glyphs
|
||||
.iter()
|
||||
.any(|g| g.start <= combining && g.end >= combining + 3),
|
||||
"fixture must shape combining cluster"
|
||||
);
|
||||
for (ordinal, range) in ranges.iter().enumerate() {
|
||||
let targets: Vec<_> = run
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.start < range.end && range.start < g.end)
|
||||
.collect();
|
||||
assert!(!targets.is_empty());
|
||||
for g in targets {
|
||||
assert!(covered(&bands, center(&run, g), ordinal));
|
||||
}
|
||||
}
|
||||
for g in run.glyphs.iter().filter(|g| {
|
||||
g.end <= 4
|
||||
|| g.start >= source.find("right").unwrap()
|
||||
|| (g.start >= source.find("abc").unwrap() && g.end <= source.find("abc").unwrap() + 3)
|
||||
|| (g.start >= source.find("xyz").unwrap() && g.end <= source.find("xyz").unwrap() + 3)
|
||||
}) {
|
||||
assert!(!(0..ranges.len()).any(|n| covered(&bands, center(&run, g), n)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_wrap_space_has_one_preceding_run_anchor_and_none_when_that_run_is_clipped_out() {
|
||||
let source = "one two three four five six seven eight nine ten";
|
||||
let mut found = None;
|
||||
for width in [60., 72., 84., 96., 108., 120., 132., 144.] {
|
||||
let e = editor(source, width, 600.);
|
||||
let gap = {
|
||||
let runs: Vec<_> = e.buffer().layout_runs().collect();
|
||||
runs.windows(2).find_map(|pair| {
|
||||
if pair[0].line_i != pair[1].line_i {
|
||||
return None;
|
||||
}
|
||||
let before = pair[0].glyphs.iter().max_by_key(|g| g.end)?;
|
||||
let after = pair[1].glyphs.iter().map(|g| g.start).min()?;
|
||||
(before.end < after && source[before.end..after].chars().all(char::is_whitespace))
|
||||
.then_some((
|
||||
before.end,
|
||||
before.x + before.w,
|
||||
pair[0].line_top,
|
||||
pair[0].line_height,
|
||||
pair[1].line_top,
|
||||
))
|
||||
})
|
||||
};
|
||||
if let Some(gap) = gap {
|
||||
found = Some((e, width, gap));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (editor, width, (byte, edge, top, height, next_top)) =
|
||||
found.expect("actual wrapping must omit a separator; do not silently skip fixture");
|
||||
let idx = index(source, std::slice::from_ref(&(byte..byte)));
|
||||
let bands = idx.bands(editor.buffer(), ORIGIN, clip(width, 600.));
|
||||
assert_eq!(
|
||||
bands.len(),
|
||||
1,
|
||||
"exactly the preceding actual run owns the gap"
|
||||
);
|
||||
assert!((bands[0].bounds.y - (ORIGIN.y + top)).abs() < 0.01);
|
||||
assert!(
|
||||
bands[0].bounds.x <= ORIGIN.x + edge
|
||||
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
|
||||
);
|
||||
assert!((bands[0].bounds.height - height).abs() < 0.01);
|
||||
let later_only = Rectangle::new(
|
||||
Point::new(ORIGIN.x, ORIGIN.y + next_top),
|
||||
Size::new(width, 100.),
|
||||
);
|
||||
assert!(
|
||||
idx.bands(editor.buffer(), ORIGIN, later_only).is_empty(),
|
||||
"must not relocate hidden prior-run anchor into next visible run"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_eof_and_separator_ranges_follow_real_paragraph_ownership() {
|
||||
for source in ["", "a", "a\n"] {
|
||||
let editor = editor(source, 200., 200.);
|
||||
let byte = source.len();
|
||||
let bands = index(source, std::slice::from_ref(&(byte..byte))).bands(
|
||||
editor.buffer(),
|
||||
ORIGIN,
|
||||
clip(200., 200.),
|
||||
);
|
||||
assert_eq!(bands.len(), 1, "EOF {source:?}");
|
||||
let run = editor.buffer().layout_runs().last().unwrap();
|
||||
assert!((bands[0].bounds.y - (ORIGIN.y + run.line_top)).abs() < 0.01);
|
||||
let edge = run.glyphs.last().map_or(0., |g| g.x + g.w);
|
||||
assert!(
|
||||
bands[0].bounds.x <= ORIGIN.x + edge
|
||||
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
|
||||
);
|
||||
}
|
||||
let source = "a\r\n\r\n雨\nz";
|
||||
let editor = editor(source, 200., 200.);
|
||||
let bands =
|
||||
index(source, &[0..source.len(), 1..3]).bands(editor.buffer(), ORIGIN, clip(200., 200.));
|
||||
let runs: Vec<_> = editor.buffer().layout_runs().collect();
|
||||
assert_eq!(runs.len(), 4);
|
||||
for run in &runs {
|
||||
let x = run.glyphs.first().map_or(2., |g| g.x + g.w / 2.);
|
||||
assert!(covered(
|
||||
&bands,
|
||||
Point::new(ORIGIN.x + x, ORIGIN.y + run.line_top + run.line_height / 2.),
|
||||
0
|
||||
));
|
||||
}
|
||||
let separator: Vec<_> = bands.iter().filter(|b| b.diagnostic == 1).collect();
|
||||
assert_eq!(separator.len(), 1);
|
||||
assert_eq!(separator[0].bounds.y, ORIGIN.y + runs[0].line_top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_affected_source_and_bottom_line_without_visible_underline_have_no_bands() {
|
||||
let actual = editor("other", 300., 100.);
|
||||
assert!(
|
||||
index("alias", std::slice::from_ref(&(0..5)))
|
||||
.bands(actual.buffer(), ORIGIN, clip(300., 100.))
|
||||
.is_empty()
|
||||
);
|
||||
let actual = editor("alias", 300., 100.);
|
||||
let run = actual.buffer().layout_runs().next().unwrap();
|
||||
let top_only = Rectangle::new(ORIGIN, Size::new(300., run.line_height / 2.));
|
||||
assert!(
|
||||
index("alias", std::slice::from_ref(&(0..5)))
|
||||
.bands(actual.buffer(), ORIGIN, top_only)
|
||||
.is_empty(),
|
||||
"partially visible text with clipped underline is not hoverable by declared policy"
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
//! Manual actual-layout timing distributions; no fixed machine threshold.
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::text::Editor,
|
||||
text::{
|
||||
Editor as _, Highlighter as _, LineHeight, Wrapping,
|
||||
highlighter::{Format, PlainText},
|
||||
},
|
||||
};
|
||||
use iced::{Font, Pixels};
|
||||
use oa_core::{
|
||||
language::{Diagnostic, Span},
|
||||
validation::Severity,
|
||||
};
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
|
||||
SourceDiagnostic {
|
||||
path: "a".into(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "perf".into(),
|
||||
message: "prepared geometry specimen".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 0,
|
||||
column: 0,
|
||||
span: Span { start, end },
|
||||
},
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn shaped(source: &str) -> Editor {
|
||||
let mut e = Editor::with_text(source);
|
||||
settle_layout(&mut e);
|
||||
e
|
||||
}
|
||||
|
||||
// Stock TextEditor calls Editor::update during the next layout after input.
|
||||
// Scroll initially changes pixel offset only; update normalizes logical scroll
|
||||
// and shapes newly visible paragraphs before draw/highlight consumes them.
|
||||
fn settle_layout(e: &mut Editor) {
|
||||
let mut h = PlainText::new(&());
|
||||
e.update(
|
||||
Size::new(640., 240.),
|
||||
Font::MONOSPACE,
|
||||
Pixels(16.),
|
||||
LineHeight::Absolute(Pixels(24.)),
|
||||
Wrapping::WordOrGlyph,
|
||||
&mut h,
|
||||
);
|
||||
e.highlight(Font::MONOSPACE, &mut h, |_| Format::default());
|
||||
}
|
||||
fn distribution<T>(label: &str, batch: usize, mut operation: impl FnMut() -> T) {
|
||||
drop(black_box(operation())); // one excluded warmup
|
||||
let mut samples = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let mut elapsed = 0;
|
||||
for _ in 0..batch {
|
||||
let start = Instant::now();
|
||||
let value = operation();
|
||||
black_box(&value);
|
||||
elapsed += start.elapsed().as_nanos();
|
||||
drop(value); // allocation/destruction separated: operation includes allocation, excludes drop
|
||||
}
|
||||
samples.push(elapsed / batch as u128);
|
||||
}
|
||||
let mut sorted = samples.clone();
|
||||
sorted.sort_unstable();
|
||||
println!(
|
||||
"TIMING {label} batch={batch} warmup=1 measured=5 ns_per_call={samples:?} min={} median={} max={} drop_excluded=true",
|
||||
sorted[0], sorted[2], sorted[4]
|
||||
);
|
||||
}
|
||||
fn run_view(label: &str, source: Arc<str>, editor: &Editor, dense: &[SourceDiagnostic]) {
|
||||
let buffer = editor.buffer();
|
||||
let runs: Vec<_> = buffer.layout_runs().collect();
|
||||
assert!(!runs.is_empty());
|
||||
let visible_glyphs: usize = runs.iter().map(|r| r.glyphs.len()).sum();
|
||||
let first = runs
|
||||
.iter()
|
||||
.find_map(|r| {
|
||||
r.glyphs
|
||||
.iter()
|
||||
.find(|g| g.w > 0.)
|
||||
.map(|g| (r.line_i, g.start, g.end))
|
||||
})
|
||||
.unwrap();
|
||||
let line_starts: Vec<_> = cosmic_text::LineIter::new(&source)
|
||||
.map(|(r, _)| r.start)
|
||||
.collect();
|
||||
let sparse = [diagnostic(
|
||||
line_starts[first.0] + first.1,
|
||||
line_starts[first.0] + first.2,
|
||||
)];
|
||||
let sparse_index = Index::new(source.clone(), "a", &sparse);
|
||||
let dense_index = Index::new(source.clone(), "a", dense);
|
||||
let empty_index = Index::new(source.clone(), "a", &[]);
|
||||
let origin = Point::new(17., 29.);
|
||||
let clip = Rectangle::new(origin, Size::new(640., 240.));
|
||||
let sparse_bands = sparse_index.bands(buffer, origin, clip);
|
||||
assert!(!sparse_bands.is_empty(), "positive visible glyph fixture");
|
||||
assert!(empty_index.bands(buffer, origin, clip).is_empty());
|
||||
let rejected = Rectangle::new(Point::new(-1000., -1000.), Size::new(1., 1.));
|
||||
assert!(
|
||||
dense_index.bands(buffer, origin, rejected).is_empty(),
|
||||
"offscreen-output negative control"
|
||||
);
|
||||
let dense_count = dense_index.bands(buffer, origin, clip).len();
|
||||
println!(
|
||||
"CASE {label} bytes={} paragraphs={} diagnostics={} visible_runs={} visible_glyphs={visible_glyphs} dense_bands={dense_count} scroll={:?}",
|
||||
source.len(),
|
||||
buffer.lines.len(),
|
||||
dense.len(),
|
||||
runs.len(),
|
||||
buffer.scroll()
|
||||
);
|
||||
distribution(&format!("{label}/index-build-dense"), 1, || {
|
||||
Index::new(black_box(source.clone()), "a", black_box(dense))
|
||||
});
|
||||
distribution(&format!("{label}/layout-runs-only"), 8, || {
|
||||
black_box(buffer)
|
||||
.layout_runs()
|
||||
.map(|r| r.glyphs.len())
|
||||
.sum::<usize>()
|
||||
});
|
||||
distribution(&format!("{label}/bands-no-diagnostics"), 8, || {
|
||||
empty_index.bands(black_box(buffer), origin, clip)
|
||||
});
|
||||
distribution(&format!("{label}/bands-one-visible"), 8, || {
|
||||
sparse_index.bands(black_box(buffer), origin, clip)
|
||||
});
|
||||
distribution(&format!("{label}/bands-dense"), 8, || {
|
||||
dense_index.bands(black_box(buffer), origin, clip)
|
||||
});
|
||||
distribution(&format!("{label}/bands-dense-rejected-clip"), 8, || {
|
||||
dense_index.bands(black_box(buffer), origin, rejected)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual diagnostic projection distributions; no fixed machine threshold"]
|
||||
fn annotation_projection_cost_distributions() {
|
||||
// Ordinary file: many logical paragraphs; deep scroll can skip by paragraph.
|
||||
let line = "alpha beta gamma delta epsilon\n";
|
||||
let ordinary: Arc<str> = Arc::from(line.repeat(4000));
|
||||
let ordinary_diagnostics: Vec<_> = (0..4000)
|
||||
.map(|i| diagnostic(i * line.len(), i * line.len() + 5))
|
||||
.collect();
|
||||
let mut ordinary_editor = shaped(&ordinary);
|
||||
run_view(
|
||||
"ordinary/top",
|
||||
ordinary.clone(),
|
||||
&ordinary_editor,
|
||||
&ordinary_diagnostics,
|
||||
);
|
||||
ordinary_editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2000 });
|
||||
settle_layout(&mut ordinary_editor);
|
||||
assert!(
|
||||
ordinary_editor.buffer().scroll().line > 1000,
|
||||
"must really reach a deep logical paragraph"
|
||||
);
|
||||
assert!(
|
||||
ordinary_editor
|
||||
.buffer()
|
||||
.layout_runs()
|
||||
.next()
|
||||
.unwrap()
|
||||
.line_i
|
||||
> 1000,
|
||||
"newly visible deep paragraph must actually be shaped"
|
||||
);
|
||||
run_view(
|
||||
"ordinary/deep",
|
||||
ordinary,
|
||||
&ordinary_editor,
|
||||
&ordinary_diagnostics,
|
||||
);
|
||||
|
||||
// Single paragraph: diagnostics near beginning remain indexed on the same
|
||||
// logical line even when the visible runs are far below all of them.
|
||||
let token = "alpha beta gamma delta ";
|
||||
let single: Arc<str> = Arc::from(token.repeat(6000));
|
||||
let dense: Vec<_> = (0..1500)
|
||||
.map(|i| diagnostic(i * token.len(), i * token.len() + 5))
|
||||
.collect();
|
||||
let mut single_editor = shaped(&single);
|
||||
let wrap_count = single_editor.buffer().lines[0].layout_opt().unwrap().len();
|
||||
assert!(wrap_count > 1000, "fixture must shape thousands of wraps");
|
||||
run_view("single/top", single.clone(), &single_editor, &dense);
|
||||
single_editor.perform(iced::widget::text_editor::Action::Scroll {
|
||||
lines: (wrap_count / 2) as i32,
|
||||
});
|
||||
settle_layout(&mut single_editor);
|
||||
let buffer = single_editor.buffer();
|
||||
assert_eq!(buffer.scroll().line, 0);
|
||||
assert!(
|
||||
buffer.scroll().vertical > 1000.,
|
||||
"must really scroll within same paragraph"
|
||||
);
|
||||
let first_visible = buffer.layout_runs().next().unwrap();
|
||||
assert!(
|
||||
first_visible.glyphs.iter().map(|g| g.start).min().unwrap()
|
||||
> dense.last().unwrap().diagnostic.span.end,
|
||||
"dense diagnostics must all be above the deep viewport"
|
||||
);
|
||||
run_view(
|
||||
"single/deep-offscreen-diagnostics",
|
||||
single,
|
||||
&single_editor,
|
||||
&dense,
|
||||
);
|
||||
println!(
|
||||
"DONE manual cost distributions; compare controls and top/deep ratios, not a platform-independent latency PASS"
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
language::{Diagnostic, Span},
|
||||
validation::Severity,
|
||||
};
|
||||
|
||||
fn diagnostic(path: &str, start: usize, end: usize) -> SourceDiagnostic {
|
||||
SourceDiagnostic {
|
||||
path: path.into(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Error,
|
||||
code: "test".into(),
|
||||
message: "synthetic admission fixture".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 999,
|
||||
column: 999,
|
||||
span: Span { start, end },
|
||||
},
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_path_and_utf8_range_admission_keep_valid_neighbor() {
|
||||
let index = Index::new(
|
||||
Arc::from("雨 x"),
|
||||
"a.arcadia",
|
||||
&[
|
||||
diagnostic("b.arcadia", 0, 3),
|
||||
diagnostic("a.arcadia", 1, 3),
|
||||
diagnostic("a.arcadia", 4, 2),
|
||||
diagnostic("a.arcadia", 0, 99),
|
||||
diagnostic("a.arcadia", 4, 5),
|
||||
],
|
||||
);
|
||||
assert_eq!(index.parts.len(), 1);
|
||||
let parts = &index.parts[&0];
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!((parts[0].diagnostic, parts[0].bytes.clone()), (4, 4..5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_endings_use_actual_paragraphs_and_preserve_eof() {
|
||||
let index = Index::new(
|
||||
Arc::from("a\r\n雨\rb\n\rc\n"),
|
||||
"a",
|
||||
&[
|
||||
diagnostic("a", 3, 6),
|
||||
diagnostic("a", 7, 8),
|
||||
diagnostic("a", 12, 12),
|
||||
],
|
||||
);
|
||||
assert_eq!(index.lines, vec![0..1, 3..6, 7..8, 10..11, 12..12]);
|
||||
assert_eq!(index.parts[&1][0].bytes, 0..3);
|
||||
assert_eq!(index.parts[&2][0].bytes, 0..1);
|
||||
assert_eq!(index.parts[&4][0].bytes, 0..0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_next_line_excluded_separator_only_and_empty_line_anchored() {
|
||||
let index = Index::new(
|
||||
Arc::from("ab\r\n\r\ncd"),
|
||||
"a",
|
||||
&[
|
||||
diagnostic("a", 0, 4),
|
||||
diagnostic("a", 2, 4),
|
||||
diagnostic("a", 4, 6),
|
||||
],
|
||||
);
|
||||
assert_eq!(index.parts.len(), 2);
|
||||
assert_eq!(index.parts[&0][0].bytes, 0..2);
|
||||
assert_eq!(index.parts[&0][1].bytes, 2..2);
|
||||
assert_eq!(index.parts[&1][0].bytes, 0..0);
|
||||
assert!(!index.parts.contains_key(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_source_has_real_zero_width_marker() {
|
||||
let index = Index::new(Arc::from(""), "a", &[diagnostic("a", 0, 0)]);
|
||||
assert_eq!(index.lines, vec![0..0]);
|
||||
assert_eq!(index.parts[&0][0].bytes, 0..0);
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
//! Permanent passive decoration around the existing editor. Draw uses current
|
||||
//! geometry; only the source index is cached, never pointer hitboxes or messages.
|
||||
//! Current-frame tooltip paragraphs stay owned until deferred renderer preparation.
|
||||
use super::Index;
|
||||
use iced::advanced::text::Paragraph as _;
|
||||
use iced::{
|
||||
Element, Event, Font, Length, Padding, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, graphics, layout, overlay, renderer, text, widget,
|
||||
},
|
||||
alignment, mouse,
|
||||
widget::text_editor,
|
||||
};
|
||||
use oa_core::{Id, source_project::SourceDiagnostic, validation::Severity};
|
||||
use std::{cell::RefCell, sync::Arc};
|
||||
|
||||
pub(crate) struct Decorations<'a, Message, R: text::Renderer> {
|
||||
child: Element<'a, Message, Theme, R>,
|
||||
content: &'a text_editor::Content<R>,
|
||||
snapshot: Option<Snapshot<'a>>,
|
||||
padding: Padding,
|
||||
hover_enabled: bool,
|
||||
}
|
||||
|
||||
/// Supplied only after the App admits a current diagnostic publication for the
|
||||
/// displayed draft. Absence keeps the editor mounted with no annotations.
|
||||
pub(crate) struct Snapshot<'a> {
|
||||
pub publication: Id,
|
||||
pub source: Arc<str>,
|
||||
pub path: &'a str,
|
||||
pub diagnostics: &'a [SourceDiagnostic],
|
||||
}
|
||||
|
||||
impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> {
|
||||
pub fn new(
|
||||
child: impl Into<Element<'a, Message, Theme, R>>,
|
||||
content: &'a text_editor::Content<R>,
|
||||
snapshot: Option<Snapshot<'a>>,
|
||||
padding: Padding,
|
||||
hover_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
child: child.into(),
|
||||
content,
|
||||
snapshot,
|
||||
padding,
|
||||
hover_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
publication: Id,
|
||||
path: String,
|
||||
source: Arc<str>,
|
||||
index: Index,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
index: RefCell<Option<Cached>>,
|
||||
details: RefCell<Option<DetailsParagraphs>>,
|
||||
}
|
||||
|
||||
// WGPU queues weak paragraph references during draw and upgrades them later.
|
||||
// This is render-resource ownership, not a cache authorizing future hover hits.
|
||||
struct DetailsParagraphs {
|
||||
_body: graphics::text::Paragraph,
|
||||
_footer: graphics::text::Paragraph,
|
||||
}
|
||||
|
||||
impl<Message, R> Widget<Message, Theme, R> for Decorations<'_, Message, R>
|
||||
where
|
||||
R: text::Renderer<
|
||||
Font = Font,
|
||||
Editor = graphics::text::Editor,
|
||||
Paragraph = graphics::text::Paragraph,
|
||||
>,
|
||||
{
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.child)]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
// A publication change only replaces the read-only index. The editor's
|
||||
// existing Tree and hidden selection/IME/scroll state remain mounted.
|
||||
tree.diff_children(std::slice::from_ref(&self.child));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.child.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.child.as_widget().size_hint()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.child
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.child
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
|
||||
) {
|
||||
// Movement within a focused editor need not change stock status.
|
||||
// Request painting, but do not capture or publish any input action.
|
||||
shell.request_redraw();
|
||||
}
|
||||
self.child.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.child.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
// Revoke the preceding frame's text even on every early/hidden path.
|
||||
// Current source admission and current geometry alone decide new text.
|
||||
let mut details = state.details.borrow_mut();
|
||||
*details = None;
|
||||
let mut cache = state.index.borrow_mut();
|
||||
let Some(snapshot) = &self.snapshot else {
|
||||
*cache = None;
|
||||
return;
|
||||
};
|
||||
let inner = layout.bounds().shrink(self.padding);
|
||||
let Some(clip) = inner
|
||||
.intersection(viewport)
|
||||
.filter(|clip| clip.width > 0. && clip.height > 0.)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if cache.as_ref().is_none_or(|cached| {
|
||||
cached.publication != snapshot.publication
|
||||
|| cached.path != snapshot.path
|
||||
|| !Arc::ptr_eq(&cached.source, &snapshot.source)
|
||||
}) {
|
||||
*cache = Some(Cached {
|
||||
publication: snapshot.publication,
|
||||
path: snapshot.path.into(),
|
||||
source: snapshot.source.clone(),
|
||||
index: Index::new(snapshot.source.clone(), snapshot.path, snapshot.diagnostics),
|
||||
});
|
||||
}
|
||||
// Child draw has now applied the actual syntax highlighter. Release the
|
||||
// Content borrow before further renderer work; no callback reentrancy.
|
||||
let mut bands = self.content.with_editor(|editor| {
|
||||
cache
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.index
|
||||
.bands(editor.buffer(), inner.position(), clip)
|
||||
});
|
||||
bands.sort_by_key(
|
||||
|band| match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
|
||||
Severity::Warning => 0,
|
||||
Severity::Error => 1,
|
||||
},
|
||||
);
|
||||
let palette = theme.palette();
|
||||
renderer.start_layer(clip);
|
||||
for band in &bands {
|
||||
let color = match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
|
||||
Severity::Warning => palette.warning,
|
||||
Severity::Error => palette.danger,
|
||||
};
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: band.underline,
|
||||
..Default::default()
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
if self.hover_enabled
|
||||
&& let Some(point) = cursor.position_over(clip)
|
||||
{
|
||||
let mut hits: Vec<_> = bands
|
||||
.iter()
|
||||
.filter(|band| band.bounds.contains(point))
|
||||
.map(|band| band.diagnostic)
|
||||
.collect();
|
||||
hits.sort_unstable();
|
||||
hits.dedup();
|
||||
hits.sort_by_key(|ordinal| {
|
||||
(
|
||||
match snapshot.diagnostics[*ordinal].diagnostic.severity {
|
||||
Severity::Error => 0,
|
||||
Severity::Warning => 1,
|
||||
},
|
||||
*ordinal,
|
||||
)
|
||||
});
|
||||
if !hits.is_empty() {
|
||||
*details = draw_details(renderer, theme, snapshot.diagnostics, &hits, point, clip);
|
||||
}
|
||||
}
|
||||
renderer.end_layer();
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.child.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut widget::Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.child.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message: 'a, R> From<Decorations<'a, Message, R>> for Element<'a, Message, Theme, R>
|
||||
where
|
||||
R: text::Renderer<
|
||||
Font = Font,
|
||||
Editor = graphics::text::Editor,
|
||||
Paragraph = graphics::text::Paragraph,
|
||||
> + 'a,
|
||||
{
|
||||
fn from(value: Decorations<'a, Message, R>) -> Self {
|
||||
Element::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_details<R: text::Renderer<Font = Font, Paragraph = graphics::text::Paragraph>>(
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
diagnostics: &[SourceDiagnostic],
|
||||
hits: &[usize],
|
||||
point: Point,
|
||||
clip: Rectangle,
|
||||
) -> Option<DetailsParagraphs> {
|
||||
const PAD: f32 = 8.;
|
||||
if clip.width < 100. || clip.height < 80. {
|
||||
return None;
|
||||
}
|
||||
let width = 520_f32.min(clip.width);
|
||||
let mut details = Vec::new();
|
||||
for ordinal in hits.iter().take(4) {
|
||||
let issue = &diagnostics[*ordinal];
|
||||
let d = &issue.diagnostic;
|
||||
let severity = match d.severity {
|
||||
Severity::Error => "Error",
|
||||
Severity::Warning => "Warning",
|
||||
};
|
||||
let raw = format!(
|
||||
"{severity} [{}] · {}:{}\n{}",
|
||||
d.code, d.line, d.column, d.message
|
||||
);
|
||||
let mut value: String = raw.chars().take(1000).collect();
|
||||
if value.len() < raw.len() {
|
||||
value.push('…');
|
||||
}
|
||||
if !issue.related.is_empty() {
|
||||
value.push_str(&format!(
|
||||
"\n{} related location(s) in Problems",
|
||||
issue.related.len()
|
||||
));
|
||||
}
|
||||
details.push(value);
|
||||
}
|
||||
let content = details.join("\n\n");
|
||||
let paragraph = graphics::text::Paragraph::with_text(text::Text {
|
||||
content: content.as_str(),
|
||||
bounds: Size::new(width - PAD * 2., f32::INFINITY),
|
||||
size: 12.into(),
|
||||
line_height: text::LineHeight::Relative(1.3),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Top,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::WordOrGlyph,
|
||||
});
|
||||
let remaining = hits.len().saturating_sub(4);
|
||||
let footer = if remaining > 0 {
|
||||
format!(
|
||||
"4 of {} shown · {remaining} more\nFull text: Actions → Source problems",
|
||||
hits.len()
|
||||
)
|
||||
} else {
|
||||
"Full text and locations: Actions → Source problems".into()
|
||||
};
|
||||
let footer_paragraph = graphics::text::Paragraph::with_text(text::Text {
|
||||
content: footer.as_str(),
|
||||
bounds: Size::new(width - PAD * 2., f32::INFINITY),
|
||||
size: 11.into(),
|
||||
line_height: text::LineHeight::Relative(1.2),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Top,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::WordOrGlyph,
|
||||
});
|
||||
let footer_height = footer_paragraph.min_bounds().height;
|
||||
let available_body = clip.height - PAD * 2. - footer_height - 4.;
|
||||
if available_body < 16. {
|
||||
return None;
|
||||
}
|
||||
let max_body_height =
|
||||
(clip.height * 0.6 - PAD * 2. - footer_height - 4.).clamp(16., available_body);
|
||||
let body_height = paragraph.min_bounds().height.min(max_body_height);
|
||||
let height = PAD * 2. + body_height + 4. + footer_height;
|
||||
let x = point.x.min(clip.x + clip.width - width).max(clip.x);
|
||||
let y = if point.y + 18. + height <= clip.y + clip.height {
|
||||
point.y + 18.
|
||||
} else {
|
||||
(point.y - 18. - height).max(clip.y)
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height));
|
||||
let palette = theme.extended_palette();
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: iced::Border {
|
||||
color: palette.background.strong.color,
|
||||
width: 1.,
|
||||
radius: 4.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
palette.background.weak.color,
|
||||
);
|
||||
let body = Rectangle::new(
|
||||
Point::new(x + PAD, y + PAD),
|
||||
Size::new(width - PAD * 2., body_height),
|
||||
);
|
||||
renderer.fill_paragraph(
|
||||
¶graph,
|
||||
body.position(),
|
||||
palette.background.weak.text,
|
||||
body,
|
||||
);
|
||||
let footer_bounds = Rectangle::new(
|
||||
Point::new(x + PAD, body.y + body.height + 4.),
|
||||
Size::new(width - PAD * 2., footer_height),
|
||||
);
|
||||
renderer.fill_paragraph(
|
||||
&footer_paragraph,
|
||||
footer_bounds.position(),
|
||||
palette.background.weak.text,
|
||||
footer_bounds,
|
||||
);
|
||||
Some(DetailsParagraphs {
|
||||
_body: paragraph,
|
||||
_footer: footer_paragraph,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,4 @@
|
||||
//! Literal source search and line navigation, independent of accepted model state.
|
||||
mod controller;
|
||||
pub mod interaction;
|
||||
pub mod query;
|
||||
pub mod selection;
|
||||
pub mod symbols;
|
||||
pub use controller::*;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Ephemeral editor navigation; no model commands or filesystem operations.
|
||||
use super::query;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
use oa_core::Id;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SymbolEvent {
|
||||
Query(query::Action),
|
||||
Back,
|
||||
Close,
|
||||
Choose {
|
||||
stamp: query::QueryStamp,
|
||||
visit: Id,
|
||||
reference: bool,
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Identity of the mounted source view that emitted an action. Message queues
|
||||
/// may outlive that view; request-time stamping alone cannot detect replay.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymbolContext {
|
||||
pub session: Id,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HistoryEntry {
|
||||
pub session: Id,
|
||||
pub path: String,
|
||||
pub generation: u64,
|
||||
pub cursor: Cursor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Request {
|
||||
pub job: Id,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: u64,
|
||||
pub stamp: query::QueryStamp,
|
||||
pub path: String,
|
||||
pub offset: usize,
|
||||
pub cursor: Cursor,
|
||||
pub action: query::Action,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Completion {
|
||||
pub request: Request,
|
||||
pub result: Result<(Arc<query::Snapshot>, Arc<query::Results>), String>,
|
||||
}
|
||||
|
||||
pub struct Reveal {
|
||||
pub ticket: Id,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: u64,
|
||||
pub snapshot: query::SnapshotKey,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct SymbolState {
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: Arc<AtomicU64>,
|
||||
pub serial: u64,
|
||||
pub in_flight: Option<Id>,
|
||||
pub request: Option<Request>,
|
||||
/// Latest admitted query owns displayed data independently of whether its
|
||||
/// original navigation intent is still current. Hiding a drawer cancels
|
||||
/// navigation, but a matching completion can still populate retained data.
|
||||
pub display_request: Option<Request>,
|
||||
pub cache: Option<Arc<query::Snapshot>>,
|
||||
pub results: Option<Arc<query::Results>>,
|
||||
pub notice: Option<String>,
|
||||
pub back: Vec<HistoryEntry>,
|
||||
pub reveal: Option<Reveal>,
|
||||
}
|
||||
|
||||
impl Default for SymbolState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interaction: Id::new_v4(),
|
||||
raw_interaction: Arc::new(AtomicU64::new(0)),
|
||||
serial: 0,
|
||||
in_flight: None,
|
||||
request: None,
|
||||
display_request: None,
|
||||
cache: None,
|
||||
results: None,
|
||||
notice: None,
|
||||
back: Vec::new(),
|
||||
reveal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iced uses zero-based UTF-8 byte columns. Reject stale or split-codepoint
|
||||
/// coordinates; do not normalize CRLF or silently clamp query destinations.
|
||||
pub fn byte_offset(text: &str, position: Position) -> Option<usize> {
|
||||
let start = if position.line == 0 {
|
||||
0
|
||||
} else {
|
||||
text.match_indices('\n').nth(position.line - 1)?.0 + 1
|
||||
};
|
||||
let end = text[start..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |end| start + end);
|
||||
let offset = start.checked_add(position.column)?;
|
||||
(offset <= end && text.is_char_boundary(offset)).then_some(offset)
|
||||
}
|
||||
|
||||
pub fn cursor_offset(text: &str, cursor: Cursor) -> Option<usize> {
|
||||
let position = cursor.selection.map_or(cursor.position, |selection| {
|
||||
if (selection.line, selection.column) < (cursor.position.line, cursor.position.column) {
|
||||
selection
|
||||
} else {
|
||||
cursor.position
|
||||
}
|
||||
});
|
||||
byte_offset(text, position)
|
||||
}
|
||||
|
||||
pub fn exact_cursor(text: &str, cursor: Cursor) -> bool {
|
||||
byte_offset(text, cursor.position).is_some()
|
||||
&& cursor
|
||||
.selection
|
||||
.is_none_or(|position| byte_offset(text, position).is_some())
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
//! Raw input invalidation and focus checks at widget-operation execution time.
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Vector, keyboard, mouse, touch};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
/// Exhaustion fails closed: a saturated clock can never authorize navigation.
|
||||
/// Replace the clock with a new workspace/controller, rather than wrapping it.
|
||||
pub fn is_current(clock: &AtomicU64, expected: u64) -> bool {
|
||||
expected != u64::MAX && clock.load(Ordering::Acquire) == expected
|
||||
}
|
||||
|
||||
fn invalidate(clock: &AtomicU64, event: &Event) {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { .. })
|
||||
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
|
||||
| Event::Touch(touch::Event::FingerPressed { .. })
|
||||
| Event::Window(iced::window::Event::Unfocused)
|
||||
) {
|
||||
let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
|
||||
Some(value.saturating_add(1))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent, including overlays: invalidate before the child can capture input.
|
||||
pub fn observe<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>(
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(Observed { content, clock })
|
||||
}
|
||||
|
||||
struct Observed<'a, Message, Theme, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
|
||||
for Observed<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
invalidate(&self.clock, event);
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let clock = self.clock.clone();
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
|
||||
}
|
||||
}
|
||||
|
||||
struct ObservedOverlay<'a, Message, Theme, Renderer> {
|
||||
content: overlay::Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
}
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||
for ObservedOverlay<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
self.content.as_overlay_mut().layout(renderer, bounds)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
self.content
|
||||
.as_overlay()
|
||||
.draw(renderer, theme, style, layout, cursor);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.operate(layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
invalidate(&self.clock, event);
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.update(event, layout, cursor, renderer, clipboard, shell);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_overlay()
|
||||
.mouse_interaction(layout, cursor, renderer)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let clock = self.clock.clone();
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.overlay(layout, renderer)
|
||||
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
|
||||
}
|
||||
fn index(&self) -> f32 {
|
||||
self.content.as_overlay().index()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect all focus owners, then focus only the mounted target. Iced executes
|
||||
/// Operation::Chain synchronously, without an intervening input event. Both
|
||||
/// traversals recheck the raw clock; no result message authorizes later focus.
|
||||
pub fn guarded_focus<Message: Send + 'static>(
|
||||
target: widget::Id,
|
||||
clock: Arc<AtomicU64>,
|
||||
expected: u64,
|
||||
) -> Task<Message> {
|
||||
widget::operate(GuardedFocus::new(target, clock, expected))
|
||||
}
|
||||
|
||||
struct GuardedFocus {
|
||||
target: widget::Id,
|
||||
clock: Arc<AtomicU64>,
|
||||
expected: u64,
|
||||
target_count: usize,
|
||||
foreign_focus: bool,
|
||||
apply: bool,
|
||||
}
|
||||
impl GuardedFocus {
|
||||
fn new(target: widget::Id, clock: Arc<AtomicU64>, expected: u64) -> Self {
|
||||
Self {
|
||||
target,
|
||||
clock,
|
||||
expected,
|
||||
target_count: 0,
|
||||
foreign_focus: false,
|
||||
apply: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: 'static> widget::Operation<Message> for GuardedFocus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation<Message>)) {
|
||||
if is_current(&self.clock, self.expected) {
|
||||
visit(self);
|
||||
}
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
if !is_current(&self.clock, self.expected) {
|
||||
return;
|
||||
}
|
||||
if id == Some(&self.target) {
|
||||
self.target_count += 1;
|
||||
if self.apply {
|
||||
state.focus();
|
||||
}
|
||||
} else if state.is_focused() {
|
||||
self.foreign_focus = true;
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> widget::operation::Outcome<Message> {
|
||||
if !self.apply
|
||||
&& self.target_count == 1
|
||||
&& !self.foreign_focus
|
||||
&& is_current(&self.clock, self.expected)
|
||||
{
|
||||
widget::operation::Outcome::Chain(Box::new(Self {
|
||||
apply: true,
|
||||
..Self::new(self.target.clone(), self.clock.clone(), self.expected)
|
||||
}))
|
||||
} else {
|
||||
widget::operation::Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -1,190 +0,0 @@
|
||||
use super::*;
|
||||
use iced::advanced::widget::operation::{Focusable, Outcome};
|
||||
use iced::widget::{column, text_input};
|
||||
use iced::{Point, Theme};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FocusMap(Vec<(Option<widget::Id>, Rectangle, bool)>);
|
||||
impl widget::Operation for FocusMap {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) {
|
||||
self.0.push((id.cloned(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
element: Element<'static, (), Theme, ()>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(clock: Arc<AtomicU64>) -> Self {
|
||||
let content = column![
|
||||
text_input("Editor", "source")
|
||||
.id("editor")
|
||||
.on_input(|_| ())
|
||||
.padding(12),
|
||||
text_input("Find", "")
|
||||
.id("find")
|
||||
.on_input(|_| ())
|
||||
.padding(12),
|
||||
]
|
||||
.spacing(10)
|
||||
.into();
|
||||
let mut element = observe(content, clock);
|
||||
let mut tree = widget::Tree::new(element.as_widget());
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(400., 200.)),
|
||||
);
|
||||
Self {
|
||||
element,
|
||||
tree,
|
||||
node,
|
||||
}
|
||||
}
|
||||
fn pass(&mut self, operation: &mut dyn widget::Operation) {
|
||||
self.element.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&self.node),
|
||||
&(),
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn run(&mut self, operation: impl widget::Operation + 'static) {
|
||||
let mut next: Box<dyn widget::Operation> = Box::new(operation);
|
||||
loop {
|
||||
self.pass(next.as_mut());
|
||||
match next.finish() {
|
||||
Outcome::Chain(chained) => next = chained,
|
||||
Outcome::None => break,
|
||||
Outcome::Some(()) => panic!("focus must not publish an application message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn focus(&mut self) -> FocusMap {
|
||||
let mut focus = FocusMap::default();
|
||||
self.pass(&mut focus);
|
||||
focus
|
||||
}
|
||||
fn click(&mut self, id: &str) {
|
||||
let bounds = self
|
||||
.focus()
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
||||
.unwrap()
|
||||
.1;
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
self.element.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Available(bounds.center()),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(Size::new(400., 200.)),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"focus-only click must not rely on App messages"
|
||||
);
|
||||
}
|
||||
fn focused(&mut self, id: &str) -> bool {
|
||||
self.focus()
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
||||
.unwrap()
|
||||
.2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_find_click_invalidates_without_app_message_and_preserves_focus() {
|
||||
let clock = Arc::new(AtomicU64::new(0));
|
||||
let mut ui = Mounted::new(clock.clone());
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
||||
assert!(ui.focused("editor"));
|
||||
ui.click("find");
|
||||
assert!(!is_current(&clock, 0));
|
||||
assert!(ui.focused("find"));
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
||||
assert!(ui.focused("find"));
|
||||
// Even a matching clock cannot authorize stealing another widget's focus.
|
||||
ui.run(GuardedFocus::new(
|
||||
"editor".into(),
|
||||
clock.clone(),
|
||||
clock.load(Ordering::Acquire),
|
||||
));
|
||||
assert!(ui.focused("find"));
|
||||
assert!(!ui.focused("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() {
|
||||
let clock = Arc::new(AtomicU64::new(0));
|
||||
let mut ui = Mounted::new(clock.clone());
|
||||
let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0);
|
||||
ui.pass(&mut operation);
|
||||
let Outcome::Chain(mut apply) = <GuardedFocus as widget::Operation<()>>::finish(&operation)
|
||||
else {
|
||||
panic!("mounted, unopposed target must be eligible");
|
||||
};
|
||||
clock.store(1, Ordering::Release);
|
||||
ui.pass(apply.as_mut());
|
||||
assert!(!ui.focused("editor"));
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1));
|
||||
assert!(ui.focused("editor"));
|
||||
ui.run(GuardedFocus::new("removed-editor".into(), clock, 1));
|
||||
assert!(ui.focused("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() {
|
||||
let clock = AtomicU64::new(0);
|
||||
let events = [
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}),
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
}),
|
||||
Event::Touch(touch::Event::FingerPressed {
|
||||
id: touch::Finger(1),
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
Event::Window(iced::window::Event::Unfocused),
|
||||
];
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
invalidate(&clock, event);
|
||||
assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1);
|
||||
}
|
||||
invalidate(
|
||||
&clock,
|
||||
&Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
);
|
||||
invalidate(&clock, &Event::Window(iced::window::Event::Focused));
|
||||
assert!(is_current(&clock, 5));
|
||||
clock.store(u64::MAX - 1, Ordering::Release);
|
||||
invalidate(&clock, &events[0]);
|
||||
assert!(!is_current(&clock, u64::MAX));
|
||||
invalidate(&clock, &events[0]);
|
||||
assert_eq!(clock.load(Ordering::Acquire), u64::MAX);
|
||||
assert!(!is_current(&clock, 0));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,295 +0,0 @@
|
||||
//! Effective selected-byte ranges for a committed departure from a source view.
|
||||
//!
|
||||
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
|
||||
//! endpoints can therefore save an empty range for a visibly selected word/line.
|
||||
//! Capture only after navigation admission and destination validation succeed,
|
||||
//! immediately before replacing/leaving the live editor. Never use this to inspect
|
||||
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
|
||||
use iced::advanced::{graphics, text};
|
||||
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
|
||||
/// Capture the effective range for restoration on fresh Content via `move_to`.
|
||||
///
|
||||
/// A nonempty selection is collapsed to its start in the departing Content; text
|
||||
/// is not edited. The returned Normal range preserves selected bytes and typing
|
||||
/// replacement, not Word/Line gesture-extension mode or an interior click point.
|
||||
/// Unequal raw endpoint direction is preserved; coincident Word/Line endpoints
|
||||
/// canonically restore with the active cursor at the effective end.
|
||||
///
|
||||
/// The renderer constraint is intentional: the inspected Iced graphics editor
|
||||
/// collapses any selection to its effective start for `Move(Left)`, and its copied
|
||||
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
|
||||
/// Only the selected text is temporarily copied; retained state is one Cursor.
|
||||
pub fn capture<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
let original = content.cursor();
|
||||
let Some(selected) = content.selection().filter(|selected| !selected.is_empty()) else {
|
||||
// Moving an unselected caret Left would be a user-visible navigation.
|
||||
return original;
|
||||
};
|
||||
let active_start = original.selection.is_some_and(|anchor| {
|
||||
(original.position.line, original.position.column) < (anchor.line, anchor.column)
|
||||
});
|
||||
content.perform(Action::Move(Motion::Left));
|
||||
let start = content.cursor().position;
|
||||
let end = match selected.rsplit_once('\n') {
|
||||
Some((prefix, suffix)) => Position {
|
||||
line: start.line + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
|
||||
column: suffix.len(),
|
||||
},
|
||||
None => Position {
|
||||
line: start.line,
|
||||
column: start.column + selected.len(),
|
||||
},
|
||||
};
|
||||
// These sums describe an existing selection in this same allocated buffer;
|
||||
// neither can exceed its line count / existing UTF-8 line length. A checked
|
||||
// error after collapsing would leave a failed departure with lost selection.
|
||||
debug_assert!(content.cursor().selection.is_none());
|
||||
debug_assert!(start.line < content.line_count() && end.line < content.line_count());
|
||||
debug_assert!((start.line, start.column) < (end.line, end.column));
|
||||
Cursor {
|
||||
position: if active_start { start } else { end },
|
||||
selection: Some(if active_start { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::widget::text_editor::Edit;
|
||||
use std::sync::Arc;
|
||||
|
||||
type EditorContent = Content<iced::Renderer>;
|
||||
|
||||
fn at(line: usize, column: usize) -> Position {
|
||||
Position { line, column }
|
||||
}
|
||||
|
||||
fn range(start: Position, end: Position, active_start: bool) -> Cursor {
|
||||
Cursor {
|
||||
position: if active_start { start } else { end },
|
||||
selection: Some(if active_start { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
fn caret(content: &mut EditorContent, position: Position) {
|
||||
content.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn roundtrip(
|
||||
source: &str,
|
||||
prepare: impl Fn(&mut EditorContent),
|
||||
selected: &str,
|
||||
expected: Cursor,
|
||||
lossy_raw: bool,
|
||||
) {
|
||||
let mut departing = EditorContent::with_text(source);
|
||||
prepare(&mut departing);
|
||||
assert_eq!(departing.text(), source);
|
||||
assert_eq!(departing.selection().as_deref(), Some(selected));
|
||||
let raw = departing.cursor();
|
||||
|
||||
// Independent direct-typing control retains the original semantic mode.
|
||||
let replacement = Action::Edit(Edit::Paste(Arc::new("替🚀".into())));
|
||||
let mut direct = EditorContent::with_text(source);
|
||||
prepare(&mut direct);
|
||||
direct.perform(replacement.clone());
|
||||
|
||||
let captured = capture(&mut departing);
|
||||
assert_eq!(captured, expected);
|
||||
assert_eq!(
|
||||
departing.text(),
|
||||
source,
|
||||
"capture cannot edit physical source bytes"
|
||||
);
|
||||
assert!(departing.cursor().selection.is_none());
|
||||
let mut restored = EditorContent::with_text(source);
|
||||
restored.move_to(captured);
|
||||
assert_eq!(restored.selection().as_deref(), Some(selected));
|
||||
assert_eq!(restored.text(), source);
|
||||
restored.perform(replacement);
|
||||
assert_eq!(
|
||||
restored.text(),
|
||||
direct.text(),
|
||||
"restored range must replace exactly the original selected bytes"
|
||||
);
|
||||
assert_eq!(restored.cursor(), direct.cursor());
|
||||
if lossy_raw {
|
||||
let mut old = EditorContent::with_text(source);
|
||||
old.move_to(raw);
|
||||
assert_ne!(
|
||||
old.selection().as_deref(),
|
||||
Some(selected),
|
||||
"negative control must discriminate raw cursor storage"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
captured, raw,
|
||||
"ordinary directional ranges are already canonical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_normal_ranges_preserve_forward_and_reverse_direction() {
|
||||
let source = "先 αβ 救助 🚀 tail\r\n";
|
||||
for active_start in [false, true] {
|
||||
let expected = range(
|
||||
at(0, "先 ".len()),
|
||||
at(0, "先 αβ 救助 🚀".len()),
|
||||
active_start,
|
||||
);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.move_to(expected),
|
||||
"αβ 救助 🚀",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_unicode_words_capture_the_selected_occurrence_without_searching() {
|
||||
let source = "éclair éclair éclair\r\n";
|
||||
let start = "éclair ".len();
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, at(0, start + "écl".len()));
|
||||
content.perform(Action::SelectWord);
|
||||
},
|
||||
"éclair",
|
||||
range(at(0, start), at(0, start + "éclair".len()), false),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_selection_expands_past_its_interior_unicode_caret() {
|
||||
let source = "first\r\n救助 🚀 tail\r\nlast";
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, at(1, "救助 ".len()));
|
||||
content.perform(Action::SelectLine);
|
||||
},
|
||||
"救助 🚀 tail",
|
||||
range(at(1, 0), at(1, "救助 🚀 tail".len()), false),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_ranges_use_copied_lf_and_utf8_columns_for_physical_crlf_and_mixed_endings() {
|
||||
for source in ["pré α\r\n中 🚀\r\nfin Ω", "pré α\n中 🚀\r\nfin Ω"] {
|
||||
for active_start in [false, true] {
|
||||
let expected = range(at(0, "pré ".len()), at(2, "fin ".len()), active_start);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.move_to(expected),
|
||||
"α\n中 🚀\nfin ",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_word_and_line_modes_capture_multiline_ranges_in_both_directions() {
|
||||
let source = "one alpha\r\ntwo beta\nthree gamma";
|
||||
for line in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let (start, end, selected) = match (line, reverse) {
|
||||
(false, false) => (at(0, 4), at(2, 11), "alpha\ntwo beta\nthree gamma"),
|
||||
(false, true) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
|
||||
(true, _) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
|
||||
};
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, if reverse { at(2, 8) } else { at(0, 6) });
|
||||
content.perform(if line {
|
||||
Action::SelectLine
|
||||
} else {
|
||||
Action::SelectWord
|
||||
});
|
||||
// Iced preserves existing Word/Line mode for Select motions.
|
||||
content.perform(Action::Select(if reverse {
|
||||
Motion::DocumentStart
|
||||
} else {
|
||||
Motion::DocumentEnd
|
||||
}));
|
||||
},
|
||||
selected,
|
||||
range(start, end, reverse),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_newline_selection_restores_endpoint_on_the_empty_last_line() {
|
||||
let source = "雨\r\n🚀\r\n";
|
||||
let expected = range(at(0, 0), at(2, 0), false);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.perform(Action::SelectAll),
|
||||
"雨\n🚀\n",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_selection_do_not_move_the_departing_caret() {
|
||||
for source in ["", "\r\n", "alpha\r\n\r\ntail"] {
|
||||
for empty_selection in [false, true] {
|
||||
let mut content = EditorContent::with_text(source);
|
||||
let position = if source.starts_with("alpha") {
|
||||
at(1, 0)
|
||||
} else {
|
||||
at(0, 0)
|
||||
};
|
||||
caret(&mut content, position);
|
||||
if empty_selection {
|
||||
content.perform(Action::SelectLine);
|
||||
assert_eq!(content.selection().as_deref(), Some(""));
|
||||
} else {
|
||||
assert!(content.selection().is_none());
|
||||
}
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection();
|
||||
assert_eq!(capture(&mut content), raw);
|
||||
assert_eq!(content.cursor(), raw);
|
||||
assert_eq!(content.selection(), selected);
|
||||
assert_eq!(content.text(), source);
|
||||
}
|
||||
}
|
||||
for empty_normal in [false, true] {
|
||||
let mut content = EditorContent::with_text("éclair");
|
||||
let position = at(0, "écl".len());
|
||||
content.move_to(Cursor {
|
||||
position,
|
||||
selection: empty_normal.then_some(position),
|
||||
});
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection();
|
||||
assert_eq!(capture(&mut content), raw);
|
||||
assert_eq!(
|
||||
content.cursor(),
|
||||
raw,
|
||||
"an unselected interior caret must not move left"
|
||||
);
|
||||
assert_eq!(content.selection(), selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
//! Pure preview and exact origin mapping for the guarded alias-rename controller.
|
||||
pub mod origin;
|
||||
pub mod preview;
|
||||
@@ -1,278 +0,0 @@
|
||||
//! Map a role-proven alias occurrence through the exact prepared source change.
|
||||
//! This is cursor restoration, not target resolution or commit authority.
|
||||
use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass};
|
||||
use std::fmt;
|
||||
|
||||
const LIMITS: OutlineLimits = OutlineLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_tokens: 1_000_000,
|
||||
max_declarations: 250_000,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OriginError {
|
||||
Budget,
|
||||
InvalidSource,
|
||||
MissingOrigin,
|
||||
ChangedStructure,
|
||||
}
|
||||
|
||||
impl fmt::Display for OriginError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.",
|
||||
Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.",
|
||||
Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.",
|
||||
Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OriginError {}
|
||||
|
||||
fn word(class: TokenClass) -> bool {
|
||||
matches!(class, TokenClass::Identifier | TokenClass::Keyword)
|
||||
}
|
||||
|
||||
/// The controller passes the exact alias span returned by the admitted symbol
|
||||
/// hit. `marker` is Some only for a reference (its separate @ span), including
|
||||
/// invocation with the caret on @. A declaration passes None.
|
||||
pub fn map(
|
||||
before: &str,
|
||||
after: &str,
|
||||
old_alias: &str,
|
||||
new_alias: &str,
|
||||
origin: Span,
|
||||
marker: Option<Span>,
|
||||
) -> Result<Span, OriginError> {
|
||||
let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
if old.has_lexical_errors || new.has_lexical_errors {
|
||||
return Err(OriginError::InvalidSource);
|
||||
}
|
||||
if old.tokens.len() != new.tokens.len() {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
let roles_before = roles(&old);
|
||||
let roles_after = roles(&new);
|
||||
let mut mapped = None;
|
||||
for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() {
|
||||
let left = &before[a.span.start..a.span.end];
|
||||
let right = &after[b.span.start..b.span.end];
|
||||
let alias_pair = word(a.class)
|
||||
&& word(b.class)
|
||||
&& left == old_alias
|
||||
&& right == new_alias
|
||||
&& roles_before[index].is_some()
|
||||
&& roles_after[index].is_some()
|
||||
&& roles_before[index].map(|role| role.is_some())
|
||||
== roles_after[index].map(|role| role.is_some());
|
||||
if a.span == origin {
|
||||
if !alias_pair || roles_before[index] != Some(marker) {
|
||||
return Err(OriginError::MissingOrigin);
|
||||
}
|
||||
mapped = Some(b.span);
|
||||
}
|
||||
if !alias_pair && (left != right || a.class != b.class) {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
}
|
||||
mapped.ok_or(OriginError::MissingOrigin)
|
||||
}
|
||||
|
||||
/// Outer None is not an alias role; Some(None) is a declaration alias;
|
||||
/// Some(Some(span)) is a live @ reference with its exact separate marker.
|
||||
/// Scan sorted declaration spans and tokens together, without repeated searches.
|
||||
fn roles(outline: &Outline<'_>) -> Vec<Option<Option<Span>>> {
|
||||
let mut result = vec![None; outline.tokens.len()];
|
||||
let mut declarations = outline.declarations.iter().peekable();
|
||||
let mut significant = None;
|
||||
for (index, token) in outline.tokens.iter().enumerate() {
|
||||
while declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span.start < token.span.start)
|
||||
{
|
||||
declarations.next();
|
||||
}
|
||||
if word(token.class) {
|
||||
if declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span == token.span)
|
||||
{
|
||||
result[index] = Some(None);
|
||||
} else if let Some(previous) = significant {
|
||||
let span: Span = previous;
|
||||
if &outline.source[span.start..span.end] == "@" {
|
||||
result[index] = Some(Some(span));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) {
|
||||
significant = Some(token.span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn occurrence(source: &str, needle: &str, which: usize) -> Span {
|
||||
let start = source.match_indices(needle).nth(which).unwrap().0;
|
||||
Span {
|
||||
start,
|
||||
end: start + needle.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() {
|
||||
let before =
|
||||
"oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n";
|
||||
let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n";
|
||||
let start = before.rfind(" a;").unwrap() + 1;
|
||||
let marker = before.rfind("@ //").unwrap();
|
||||
let mapped = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"renamed",
|
||||
Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
},
|
||||
Some(Span {
|
||||
start: marker,
|
||||
end: marker + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], "renamed");
|
||||
assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1);
|
||||
assert_eq!(
|
||||
before[..start].bytes().filter(|b| *b == b'\n').count(),
|
||||
after[..mapped.start]
|
||||
.bytes()
|
||||
.filter(|b| *b == b'\n')
|
||||
.count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_final_same_line_occurrence_not_first_or_prose() {
|
||||
let before = "oa_activity a \"a @a\" {} source @a; target @a;";
|
||||
let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let result = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
},
|
||||
Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.start, after.rfind("@longer").unwrap() + 1);
|
||||
let prose = occurrence(before, "a @a", 0);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: prose.start,
|
||||
end: prose.start + 1
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_identifier_keyword_transitions_in_alias_roles() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let alias = occurrence(before, "a \"Name", 0);
|
||||
let origin = Span {
|
||||
start: alias.start,
|
||||
end: alias.start + 1,
|
||||
};
|
||||
for replacement in ["none", "include", "oa_activity"] {
|
||||
assert!(oa_core::valid_alias(replacement));
|
||||
assert_eq!(
|
||||
language::tokenize(replacement)[0].class,
|
||||
TokenClass::Keyword
|
||||
);
|
||||
let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};");
|
||||
let mapped = map(before, &after, "a", replacement, origin, None).unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], replacement);
|
||||
assert_eq!(
|
||||
map(&after, before, replacement, "a", mapped, None).unwrap(),
|
||||
origin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_changed_trivia_non_alias_words_and_wrong_marker() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let after = "oa_activity b \"Name\" {} source @b;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let span = Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
};
|
||||
let marker = Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
});
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace("Name", "Other"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace(" source", " source"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(before, after, "a", "b", span, None),
|
||||
Err(OriginError::MissingOrigin)
|
||||
);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"b",
|
||||
Span {
|
||||
start: 0,
|
||||
end: usize::MAX
|
||||
},
|
||||
marker
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
//! Complete, bounded line-ordinal previews of an already prepared alias rename.
|
||||
//!
|
||||
//! Source text remains in the caller's immutable before/after snapshots. This
|
||||
//! module owns only paths and indexes; it is not rename or commit authority.
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{fmt, ops::Range};
|
||||
|
||||
const CONTEXT: usize = 3;
|
||||
// A byte-grid boundary can move back by at most three UTF-8 continuation bytes,
|
||||
// so a 252-byte grid yields fragments of at most 255 bytes, independent of input.
|
||||
const SEGMENT_GRID: usize = 252;
|
||||
|
||||
/// Count all bounded fragments of exact source text; an empty line has one
|
||||
/// empty fragment. Page these as well as lines, never mount an unbounded line.
|
||||
pub fn segment_count(text: &str) -> usize {
|
||||
text.len().div_ceil(SEGMENT_GRID).max(1)
|
||||
}
|
||||
|
||||
/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the
|
||||
/// original bytes, including CRLF. No ellipsis or truncation replaces content.
|
||||
pub fn text_segment(text: &str, index: usize) -> Option<&str> {
|
||||
if index >= segment_count(text) {
|
||||
return None;
|
||||
}
|
||||
let mut start = index.checked_mul(SEGMENT_GRID)?;
|
||||
let mut end = start.saturating_add(SEGMENT_GRID).min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(start..end)
|
||||
}
|
||||
const LIMITS: Limits = Limits {
|
||||
files: 256,
|
||||
changed_lines: 50_000,
|
||||
display_bytes: 8 * 1024 * 1024,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
files: usize,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Preview {
|
||||
files: Vec<File>,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct File {
|
||||
path: String,
|
||||
hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Hunk {
|
||||
lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Line {
|
||||
number: usize,
|
||||
before: Range<usize>,
|
||||
after: Range<usize>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PreviewError {
|
||||
MembershipChanged,
|
||||
LineCountChanged(String),
|
||||
LineEndingChanged { path: String, line: usize },
|
||||
TooManyFiles { limit: usize },
|
||||
TooManyChangedLines { limit: usize },
|
||||
TooManyDisplayBytes { limit: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for PreviewError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MembershipChanged => write!(
|
||||
f,
|
||||
"Rename preview refused: the entry or source file membership changed. No rename was applied."
|
||||
),
|
||||
Self::LineCountChanged(path) => write!(
|
||||
f,
|
||||
"Rename preview refused: the line count changed in {path}. No rename was applied."
|
||||
),
|
||||
Self::LineEndingChanged { path, line } => write!(
|
||||
f,
|
||||
"Rename preview refused: the line ending changed in {path}:{line}. No rename was applied."
|
||||
),
|
||||
Self::TooManyFiles { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyChangedLines { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyDisplayBytes { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PreviewError {}
|
||||
|
||||
impl Preview {
|
||||
pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result<Self, PreviewError> {
|
||||
Self::with_limits(before, after, LIMITS)
|
||||
}
|
||||
|
||||
fn with_limits(
|
||||
before: &SourceBundle,
|
||||
after: &SourceBundle,
|
||||
limits: Limits,
|
||||
) -> Result<Self, PreviewError> {
|
||||
if before.entry != after.entry
|
||||
|| !before.files.contains_key(&before.entry)
|
||||
|| !before.files.keys().eq(after.files.keys())
|
||||
{
|
||||
return Err(PreviewError::MembershipChanged);
|
||||
}
|
||||
let mut preview = Self {
|
||||
files: Vec::new(),
|
||||
changed_lines: 0,
|
||||
display_bytes: 0,
|
||||
};
|
||||
for ((path, old), (_, new)) in before.files.iter().zip(&after.files) {
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
if preview.files.len() == limits.files {
|
||||
return Err(PreviewError::TooManyFiles {
|
||||
limit: limits.files,
|
||||
});
|
||||
}
|
||||
// A changed source line can contain arbitrarily many renamed tokens.
|
||||
// Count the line once; this count is never a semantic-reference count.
|
||||
let mut changed = Vec::new();
|
||||
let mut old_lines = Lines::new(old);
|
||||
let mut new_lines = Lines::new(new);
|
||||
let mut count = 0;
|
||||
loop {
|
||||
match (old_lines.next(), new_lines.next()) {
|
||||
(None, None) => break,
|
||||
(Some(a), Some(b)) => {
|
||||
count += 1;
|
||||
if old[a.content_end..a.end] != new[b.content_end..b.end] {
|
||||
return Err(PreviewError::LineEndingChanged {
|
||||
path: path.clone(),
|
||||
line: count,
|
||||
});
|
||||
}
|
||||
if old[a.start..a.end] != new[b.start..b.end] {
|
||||
if preview.changed_lines == limits.changed_lines {
|
||||
return Err(PreviewError::TooManyChangedLines {
|
||||
limit: limits.changed_lines,
|
||||
});
|
||||
}
|
||||
preview.changed_lines += 1;
|
||||
changed.push(count - 1);
|
||||
}
|
||||
}
|
||||
_ => return Err(PreviewError::LineCountChanged(path.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// Merge touching context intervals in order, without pairwise diff.
|
||||
// At most 7 indexed lines per changed line are retained; unchanged
|
||||
// lines outside these intervals are scanned but never indexed.
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
for line in changed {
|
||||
let range =
|
||||
line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count);
|
||||
if let Some(previous) = ranges.last_mut()
|
||||
&& range.start <= previous.end
|
||||
{
|
||||
previous.end = previous.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
ranges.push(range);
|
||||
}
|
||||
preview.charge(path.len(), limits)?;
|
||||
let mut file = File {
|
||||
path: path.clone(),
|
||||
hunks: Vec::new(),
|
||||
};
|
||||
let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate();
|
||||
for range in ranges {
|
||||
let mut hunk = Hunk { lines: Vec::new() };
|
||||
for (ordinal, (a, b)) in pairs.by_ref() {
|
||||
if ordinal < range.start {
|
||||
continue;
|
||||
}
|
||||
// Charge exact UTF-8 bytes, including preserved endings and
|
||||
// both copies of shared context, before adding the index.
|
||||
// No render-time omission or shortening grants admission.
|
||||
preview.charge(a.end - a.start, limits)?;
|
||||
preview.charge(b.end - b.start, limits)?;
|
||||
hunk.lines.push(Line {
|
||||
number: ordinal + 1,
|
||||
changed: old[a.start..a.end] != new[b.start..b.end],
|
||||
before: a.start..a.end,
|
||||
after: b.start..b.end,
|
||||
});
|
||||
if ordinal + 1 == range.end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
file.hunks.push(hunk);
|
||||
}
|
||||
preview.files.push(file);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> {
|
||||
let Some(total) = self.display_bytes.checked_add(bytes) else {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
};
|
||||
if total > limits.display_bytes {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
}
|
||||
self.display_bytes = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All admitted files in source path order; suitable for a file picker/search.
|
||||
pub fn files(&self) -> &[File] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
/// Changed source lines, not occurrences, declarations or semantic uses.
|
||||
pub fn changed_line_count(&self) -> usize {
|
||||
self.changed_lines
|
||||
}
|
||||
|
||||
/// Exact displayed source bytes for both sides plus each stored path once.
|
||||
/// Index metadata is separately bounded by 7 lines per changed line.
|
||||
pub fn display_bytes(&self) -> usize {
|
||||
self.display_bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Every hunk is present; the caller may page these indexes, never truncate.
|
||||
pub fn hunks(&self) -> &[Hunk] {
|
||||
&self.hunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
/// The complete ordered line slice supports paging even a large single hunk.
|
||||
pub fn lines(&self) -> &[Line] {
|
||||
&self.lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// One-based source line number, identical on both sides.
|
||||
pub fn number(&self) -> usize {
|
||||
self.number
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// Borrow from the immutable original file; includes its exact line ending.
|
||||
pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> {
|
||||
original.get(self.before.clone())
|
||||
}
|
||||
|
||||
/// Borrow from the immutable candidate file; includes its exact line ending.
|
||||
pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> {
|
||||
candidate.get(self.after.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct RawLine {
|
||||
start: usize,
|
||||
content_end: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
struct Lines<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lines<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
bytes: text.as_bytes(),
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Lines<'_> {
|
||||
type Item = RawLine;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.offset == self.bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let start = self.offset;
|
||||
// The compiler and source navigation count LF, not bare CR, as a line
|
||||
// boundary. Preserve CRLF as an ending and bare CR as source content.
|
||||
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
|
||||
self.offset += 1;
|
||||
}
|
||||
let mut content_end = self.offset;
|
||||
if self.offset < self.bytes.len() {
|
||||
if self.offset > start && self.bytes[self.offset - 1] == b'\r' {
|
||||
content_end -= 1;
|
||||
}
|
||||
self.offset += 1;
|
||||
}
|
||||
Some(RawLine {
|
||||
start,
|
||||
content_end,
|
||||
end: self.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "救援.arcadia".into(),
|
||||
files: [("救援.arcadia".into(), source.into())].into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() {
|
||||
let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200));
|
||||
let pieces: Vec<_> = (0..segment_count(&text))
|
||||
.map(|i| text_segment(&text, i).unwrap())
|
||||
.collect();
|
||||
assert!(pieces.len() > 2);
|
||||
assert!(pieces.iter().all(|part| part.len() <= 255));
|
||||
assert_eq!(pieces.concat(), text);
|
||||
assert_eq!(text_segment(&text, segment_count(&text)), None);
|
||||
assert_eq!(text_segment(&text, usize::MAX), None);
|
||||
assert_eq!(segment_count(""), 1);
|
||||
assert_eq!(text_segment("", 0), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() {
|
||||
let old = bundle("// 救援\r\n@a @a\ncontext\r@a");
|
||||
let new = bundle("// 救援\r\n@long @long\ncontext\r@long");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 2);
|
||||
let file = &preview.files()[0];
|
||||
assert_eq!(file.path(), "救援.arcadia");
|
||||
assert_eq!(file.hunks().len(), 1);
|
||||
let lines = file.hunks()[0].lines();
|
||||
assert_eq!(
|
||||
lines.iter().map(Line::number).collect::<Vec<_>>(),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
lines[0].before_text(&old.files[&old.entry]),
|
||||
Some("// 救援\r\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1].after_text(&new.files[&new.entry]),
|
||||
Some("@long @long\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[2].after_text(&new.files[&new.entry]),
|
||||
Some("context\r@long")
|
||||
);
|
||||
assert!(lines[1].changed() && lines[2].changed());
|
||||
assert!(!lines[0].changed());
|
||||
assert_eq!(
|
||||
preview.display_bytes(),
|
||||
old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_last_lines_form_complete_separate_hunks_and_pages() {
|
||||
let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na");
|
||||
let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
let hunks = preview.files()[0].hunks();
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let page_numbers: Vec<_> = hunks
|
||||
.iter()
|
||||
.flat_map(|h| h.lines().chunks(2))
|
||||
.flat_map(|page| page.iter().map(Line::number))
|
||||
.collect();
|
||||
assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]);
|
||||
assert_eq!(
|
||||
hunks[1].lines()[3].after_text(&new.files[&new.entry]),
|
||||
Some("long")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_changes_merge_and_all_changed_files_are_reachable() {
|
||||
let mut old = bundle("a\n1\n2\n3\n4\n5\na\n");
|
||||
let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n");
|
||||
old.files.insert("other.arcadia".into(), "a a a".into());
|
||||
new.files.insert("other.arcadia".into(), "b b b".into());
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 3);
|
||||
assert_eq!(preview.files().len(), 2);
|
||||
assert_eq!(preview.files()[0].path(), "other.arcadia");
|
||||
assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_empty_sources_have_no_preview() {
|
||||
for source in ["", "a\r\nb\n", "a"] {
|
||||
let original = bundle(source);
|
||||
let preview = Preview::build(&original, &original).unwrap();
|
||||
assert!(preview.is_empty());
|
||||
assert_eq!(preview.changed_line_count(), 0);
|
||||
assert_eq!(preview.display_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_membership_entry_line_count_and_line_ending_changes() {
|
||||
let old = bundle("a\nb");
|
||||
let mut new = old.clone();
|
||||
new.files.insert("extra.arcadia".into(), "".into());
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
let mut new = old.clone();
|
||||
new.entry = "missing.arcadia".into();
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
assert!(matches!(
|
||||
Preview::build(&old, &bundle("a\nb\nc")),
|
||||
Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("a\nb\n"), &bundle("a\n")),
|
||||
Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] {
|
||||
assert_eq!(
|
||||
Preview::build(&bundle(a), &bundle(b)).unwrap_err(),
|
||||
PreviewError::LineEndingChanged {
|
||||
path: old.entry.clone(),
|
||||
line: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() {
|
||||
let old = bundle("a\ncontext\na");
|
||||
let new = bundle("bbb\ncontext\nbbb");
|
||||
let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len();
|
||||
let limits = Limits {
|
||||
files: 1,
|
||||
changed_lines: 2,
|
||||
display_bytes: exact,
|
||||
};
|
||||
assert!(Preview::with_limits(&old, &new, limits).is_ok());
|
||||
assert_eq!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(),
|
||||
PreviewError::TooManyFiles { limit: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyChangedLines { limit: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: exact - 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyDisplayBytes { limit: exact - 1 }
|
||||
);
|
||||
let long = bundle(&"a".repeat(LIMITS.display_bytes));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("b"), &long),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_whole_preview_not_each_file() {
|
||||
let mut old = bundle("a");
|
||||
let mut new = bundle("b");
|
||||
old.files.insert("second.arcadia".into(), "a".into());
|
||||
new.files.insert("second.arcadia".into(), "b".into());
|
||||
let limits = Limits {
|
||||
files: 2,
|
||||
changed_lines: 2,
|
||||
display_bytes: old.entry.len() + "second.arcadia".len() + 4,
|
||||
};
|
||||
let complete = Preview::with_limits(&old, &new, limits).unwrap();
|
||||
assert_eq!(complete.files().len(), 2);
|
||||
assert_eq!(complete.changed_line_count(), 2);
|
||||
assert_eq!(complete.display_bytes(), limits.display_bytes);
|
||||
assert!(matches!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 1, ..limits }),
|
||||
Err(PreviewError::TooManyFiles { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyChangedLines { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: limits.display_bytes - 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Per-file drafts and navigation. Accepted model/source history lives in Document.
|
||||
use iced::widget::text_editor::Cursor;
|
||||
use oa_core::{
|
||||
Id, language,
|
||||
language,
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -86,7 +86,6 @@ pub struct Buffer {
|
||||
pub cursor: Cursor,
|
||||
past: Vec<String>,
|
||||
future: Vec<String>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
@@ -100,20 +99,14 @@ impl Buffer {
|
||||
},
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.text != self.baseline
|
||||
}
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
session: Id,
|
||||
revision: u64,
|
||||
pub entry: String,
|
||||
pub active: String,
|
||||
pub files: BTreeMap<String, Buffer>,
|
||||
@@ -124,35 +117,6 @@ pub struct Workspace {
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Ephemeral editor identity, never a model identity or recovery payload.
|
||||
pub fn session(&self) -> Id {
|
||||
self.session
|
||||
}
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
fn advance(&mut self) -> u64 {
|
||||
if let Some(revision) = self.revision.checked_add(1) {
|
||||
self.revision = revision;
|
||||
} else {
|
||||
self.reset_navigation_session();
|
||||
}
|
||||
self.revision
|
||||
}
|
||||
/// A changed filesystem root must not reuse queued navigation from Save As.
|
||||
pub fn reset_navigation_session(&mut self) {
|
||||
self.session = Id::new_v4();
|
||||
self.revision = 0;
|
||||
for buffer in self.files.values_mut() {
|
||||
buffer.generation = 0;
|
||||
}
|
||||
}
|
||||
pub fn set_reachable(&mut self, reachable: BTreeSet<String>) {
|
||||
if self.reachable != reachable {
|
||||
self.advance();
|
||||
self.reachable = reachable;
|
||||
}
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
|
||||
RecoveryWorkspace {
|
||||
entry: self.entry.clone(),
|
||||
@@ -172,8 +136,6 @@ impl Workspace {
|
||||
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
|
||||
saved.validate()?;
|
||||
Ok(Self {
|
||||
session: Id::new_v4(),
|
||||
revision: 0,
|
||||
entry: saved.entry,
|
||||
active: saved.active,
|
||||
files: saved
|
||||
@@ -192,8 +154,6 @@ impl Workspace {
|
||||
}
|
||||
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
|
||||
Self {
|
||||
session: Id::new_v4(),
|
||||
revision: 0,
|
||||
entry: bundle.entry.clone(),
|
||||
active: bundle.entry.clone(),
|
||||
files: bundle
|
||||
@@ -231,20 +191,11 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
pub fn edit(&mut self, text: String, cursor: Cursor) {
|
||||
let changed = self.files.get(&self.active).is_none_or(|b| b.text != text);
|
||||
let generation = if changed {
|
||||
self.advance()
|
||||
} else {
|
||||
self.revision
|
||||
};
|
||||
let buffer = self
|
||||
.files
|
||||
.entry(self.active.clone())
|
||||
.or_insert_with(|| Buffer::new(String::new()));
|
||||
buffer.cursor = cursor;
|
||||
if changed {
|
||||
buffer.generation = generation;
|
||||
}
|
||||
if text != buffer.text {
|
||||
buffer.past.push(std::mem::replace(&mut buffer.text, text));
|
||||
if buffer.past.len() > 100 {
|
||||
@@ -317,8 +268,6 @@ impl Workspace {
|
||||
} else {
|
||||
buffer.future.push(previous);
|
||||
}
|
||||
let generation = self.advance();
|
||||
self.files.get_mut(&self.active).unwrap().generation = generation;
|
||||
self.trim_history(DRAFT_HISTORY_BYTES);
|
||||
true
|
||||
}
|
||||
@@ -332,7 +281,6 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
pub fn accept(&mut self, bundle: &SourceBundle) {
|
||||
let generation = self.advance();
|
||||
if self.entry != bundle.entry
|
||||
&& !bundle.files.contains_key(&self.entry)
|
||||
&& self.files.get(&self.entry).is_some_and(|b| !b.dirty())
|
||||
@@ -343,13 +291,7 @@ impl Workspace {
|
||||
self.reachable = bundle.files.keys().cloned().collect();
|
||||
for (path, text) in &bundle.files {
|
||||
let cursor = self.files.get(path).map(|b| b.cursor);
|
||||
let previous_generation = self
|
||||
.files
|
||||
.get(path)
|
||||
.filter(|b| b.text == *text)
|
||||
.map(|b| b.generation);
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = previous_generation.unwrap_or(generation);
|
||||
if let Some(cursor) = cursor {
|
||||
buffer.cursor = cursor;
|
||||
}
|
||||
@@ -361,22 +303,10 @@ impl Workspace {
|
||||
self.applied = true;
|
||||
}
|
||||
pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet<String>) {
|
||||
let changed = self.reachable != reachable
|
||||
|| bundle
|
||||
.files
|
||||
.keys()
|
||||
.any(|path| !self.files.contains_key(path));
|
||||
let generation = if changed {
|
||||
self.advance()
|
||||
} else {
|
||||
self.revision
|
||||
};
|
||||
for (path, text) in &bundle.files {
|
||||
self.files.entry(path.clone()).or_insert_with(|| {
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = generation;
|
||||
buffer
|
||||
});
|
||||
self.files
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| Buffer::new(text.clone()));
|
||||
}
|
||||
self.reachable = reachable;
|
||||
}
|
||||
@@ -384,7 +314,6 @@ impl Workspace {
|
||||
if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) {
|
||||
return false;
|
||||
}
|
||||
self.advance();
|
||||
self.files.remove(path);
|
||||
if self.active == path {
|
||||
self.active = self.entry.clone();
|
||||
@@ -447,148 +376,6 @@ pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::Project;
|
||||
|
||||
#[test]
|
||||
fn fresh_session_cannot_reuse_a_retained_buffer_generation() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "main.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("main.arcadia".into(), "first".into()),
|
||||
("other.arcadia".into(), "other".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let cursor = workspace.files["main.arcadia"].cursor;
|
||||
workspace.edit("second".into(), cursor);
|
||||
workspace.reset_navigation_session();
|
||||
let before = workspace.files["main.arcadia"].generation();
|
||||
workspace.edit("third".into(), cursor);
|
||||
assert_ne!(workspace.files["main.arcadia"].generation(), before);
|
||||
workspace.switch("other.arcadia", cursor);
|
||||
workspace.revision = u64::MAX;
|
||||
workspace.edit("changed other".into(), cursor);
|
||||
let after_wrap = workspace.files["main.arcadia"].generation();
|
||||
workspace.switch("main.arcadia", cursor);
|
||||
workspace.edit("fourth".into(), cursor);
|
||||
assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.text(), "third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "entry\r\n".into()),
|
||||
("part.arcadia".into(), "雨 draft\r\n".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let reopened = Workspace::new(&bundle, true);
|
||||
let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap();
|
||||
assert_ne!(workspace.session(), reopened.session());
|
||||
assert_ne!(workspace.session(), recovered.session());
|
||||
assert_eq!(workspace.bundle(), recovered.bundle());
|
||||
let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap();
|
||||
assert!(saved.get("session").is_none() && saved.get("revision").is_none());
|
||||
|
||||
let cursor = Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
let revision = workspace.revision();
|
||||
workspace.edit(workspace.text().into(), cursor);
|
||||
assert_eq!(
|
||||
workspace.revision(),
|
||||
revision,
|
||||
"selection is not a text change"
|
||||
);
|
||||
workspace.switch("part.arcadia", cursor);
|
||||
assert_eq!(
|
||||
workspace.revision(),
|
||||
revision,
|
||||
"switching retains the snapshot"
|
||||
);
|
||||
let untouched = workspace.files["project.arcadia"].generation();
|
||||
workspace.edit("雨 unfinished !\r\n".into(), cursor);
|
||||
let edited = workspace.files["part.arcadia"].generation();
|
||||
assert!(workspace.revision() > revision);
|
||||
assert_eq!(workspace.files["project.arcadia"].generation(), untouched);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
assert!(workspace.files["part.arcadia"].generation() > edited);
|
||||
assert!(workspace.undo(true));
|
||||
assert_eq!(workspace.text(), "雨 unfinished !\r\n");
|
||||
let previous_session = workspace.session();
|
||||
let previous_text = workspace.bundle();
|
||||
workspace.reset_navigation_session();
|
||||
assert_ne!(workspace.session(), previous_session);
|
||||
assert_eq!(workspace.bundle(), previous_text);
|
||||
assert!(
|
||||
workspace.undo(false),
|
||||
"session reset preserves text history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "entry".into()),
|
||||
("part.arcadia".into(), "part".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let entry_generation = workspace.files[&bundle.entry].generation();
|
||||
let initial_revision = workspace.revision();
|
||||
workspace.set_reachable(BTreeSet::from([bundle.entry.clone()]));
|
||||
assert!(workspace.revision() > initial_revision);
|
||||
let membership_revision = workspace.revision();
|
||||
workspace.set_reachable(workspace.reachable.clone());
|
||||
assert_eq!(workspace.revision(), membership_revision);
|
||||
assert!(workspace.discard_detached("part.arcadia"));
|
||||
assert!(workspace.revision() > membership_revision);
|
||||
let closed_revision = workspace.revision();
|
||||
workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()]));
|
||||
assert!(workspace.revision() > closed_revision);
|
||||
assert_eq!(
|
||||
workspace.files[&bundle.entry].generation(),
|
||||
entry_generation
|
||||
);
|
||||
assert!(workspace.files["part.arcadia"].generation() > closed_revision);
|
||||
let discovered_revision = workspace.revision();
|
||||
workspace.discovered(&bundle, workspace.reachable.clone());
|
||||
assert_eq!(workspace.revision(), discovered_revision);
|
||||
workspace.accept(&bundle);
|
||||
assert!(
|
||||
workspace.revision() > discovered_revision,
|
||||
"validation evidence changed"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.files[&bundle.entry].generation(),
|
||||
entry_generation
|
||||
);
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), "original".into())]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
workspace.revision = u64::MAX;
|
||||
let before = workspace.session();
|
||||
let cursor = workspace.files[&bundle.entry].cursor;
|
||||
workspace.edit("changed".into(), cursor);
|
||||
assert_ne!(workspace.session(), before);
|
||||
assert_eq!(workspace.revision(), 0);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
}
|
||||
#[test]
|
||||
fn draft_history_budget_covers_inactive_files_without_discarding_drafts() {
|
||||
let bundle = SourceBundle {
|
||||
|
||||
@@ -377,8 +377,7 @@ fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert_eq!(app.page, Page::Text);
|
||||
assert!(app.source_jump.is_some());
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
send(&mut app, Message::SourceViewport);
|
||||
assert!(app.source_jump.is_none());
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert!(app.source_jump.is_some());
|
||||
@@ -410,8 +409,7 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
|
||||
cursor.selection,
|
||||
Some(text_editor::Position { line: 0, column: 9 })
|
||||
);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
send(&mut app, Message::SourceViewport);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
send(
|
||||
&mut app,
|
||||
|
||||
+156
-303
@@ -1,5 +1,3 @@
|
||||
mod source_symbols;
|
||||
mod text_workspace;
|
||||
pub(crate) mod traceability;
|
||||
use crate::{
|
||||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||||
@@ -230,15 +228,13 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
Page::Guide => crate::guide::view(&app.guide),
|
||||
};
|
||||
let process_layout = app.topology_context().is_some();
|
||||
let scenario_layout = app.scenario_context().is_some();
|
||||
let focus_behavior = (process_layout && app.behavior.topology.focused)
|
||||
|| (scenario_layout && app.behavior.scenario.focused());
|
||||
let focus_process = process_layout && app.behavior.topology.focused;
|
||||
let mut body = if app.page == Page::Home {
|
||||
row![container(main).width(Fill).height(Fill)].height(Fill)
|
||||
} else if process_layout || scenario_layout {
|
||||
} else if process_layout {
|
||||
// Keep main at the same widget-tree path across Focus toggles. The
|
||||
// collapsed slot has no controls, so it cannot paint over or hit the canvas.
|
||||
let process_sidebar: View<'_, Message> = if focus_behavior {
|
||||
let process_sidebar: View<'_, Message> = if focus_process {
|
||||
Space::new().width(0).into()
|
||||
} else {
|
||||
sidebar.into()
|
||||
@@ -246,7 +242,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
// Row::push would discard the zero-width slot and shift main again.
|
||||
widget::Row::from_vec(vec![
|
||||
container(process_sidebar)
|
||||
.width(if focus_behavior { 0 } else { 260 })
|
||||
.width(if focus_process { 0 } else { 260 })
|
||||
.height(Fill)
|
||||
.clip(true)
|
||||
.into(),
|
||||
@@ -257,7 +253,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
} else {
|
||||
row![sidebar, container(main).width(Fill).height(Fill)].height(Fill)
|
||||
};
|
||||
if !focus_behavior && !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
|
||||
if !focus_process && !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
|
||||
body = body.push(inspector(app));
|
||||
}
|
||||
let footer = container(
|
||||
@@ -277,7 +273,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
p.relations.len()
|
||||
)),
|
||||
action(
|
||||
&format!("{} model checks", app.issue_count()),
|
||||
&format!("{} checks", app.issue_count()),
|
||||
Message::Page(Page::Validation)
|
||||
)
|
||||
]
|
||||
@@ -351,13 +347,6 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
if app.pending.is_some() {
|
||||
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
|
||||
}
|
||||
if app.source_rename.open() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::source_rename::dialog(app),
|
||||
(app.window_size.width - 32.).clamp(120., 760.),
|
||||
);
|
||||
}
|
||||
root
|
||||
}
|
||||
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
|
||||
@@ -1011,317 +1000,181 @@ fn source_files(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
|
||||
fn source(app: &App) -> View<'_, Message> {
|
||||
use crate::app::text_workspace::DrawerTab;
|
||||
use oa_core::source_project::SourceLocation;
|
||||
let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems);
|
||||
let publication = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.filter(|publication| visible && publication.source() == app.source_key());
|
||||
let mut diagnostics = column![].spacing(3).width(Fill);
|
||||
if visible {
|
||||
if publication.is_none() && !app.source_diagnostics.is_empty() {
|
||||
diagnostics = diagnostics
|
||||
.push(text("Source changed. Waiting for current source checks…").size(12));
|
||||
}
|
||||
if app.source_diagnostics.is_empty() {
|
||||
diagnostics = diagnostics.push(text("No source problems reported.").size(12));
|
||||
}
|
||||
for issue in &app.source_diagnostics {
|
||||
let location = SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
use crate::source_navigation::Event as SourceEvent;
|
||||
let navigation: View<'_, Message> = if app.source_navigation.open {
|
||||
column![
|
||||
row![
|
||||
text_input("Find literal text…", &app.source_navigation.query)
|
||||
.id("source-find")
|
||||
.on_input(|query| Message::SourceNavigation(SourceEvent::Query(query)))
|
||||
.on_submit(Message::SourceNavigation(SourceEvent::Next(false)))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
action(
|
||||
"Previous",
|
||||
Message::SourceNavigation(SourceEvent::Next(true))
|
||||
),
|
||||
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
|
||||
label(app.source_navigation.label()),
|
||||
action("Close", Message::SourceNavigation(SourceEvent::Close)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
text_input("Line", &app.source_navigation.line)
|
||||
.id("source-line")
|
||||
.on_input(|line| Message::SourceNavigation(SourceEvent::Line(line)))
|
||||
.on_submit(Message::SourceNavigation(SourceEvent::Go))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(80),
|
||||
action("Go to line", Message::SourceNavigation(SourceEvent::Go)),
|
||||
label("Enter or F3: next match · Shift F3: previous · Esc: close"),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
let severity = match issue.diagnostic.severity {
|
||||
validation::Severity::Error => "Error",
|
||||
validation::Severity::Warning => "Warning",
|
||||
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
|
||||
let edits_block_recovery =
|
||||
!app.source_workspace.applied && app.source_workspace.has_edits();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
container(
|
||||
column![
|
||||
tooltip(
|
||||
text(format!("Interrupted save · {filename}")).size(15),
|
||||
text(path.display().to_string()).size(12),
|
||||
tooltip::Position::Bottom,
|
||||
),
|
||||
text(if edits_block_recovery {
|
||||
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
|
||||
} else if app.source_workspace.applied {
|
||||
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
|
||||
} else {
|
||||
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
|
||||
})
|
||||
.size(13),
|
||||
button(text("Recover interrupted save").size(12))
|
||||
.padding(10)
|
||||
.on_press_maybe(
|
||||
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
|
||||
),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
let mut diagnostics = column![].spacing(5);
|
||||
let mut visible_diagnostics = 0;
|
||||
for (index, issue) in app.source_diagnostics.iter().enumerate() {
|
||||
// The persistent recovery banner supplies the action and explanation;
|
||||
// repeating the internal journal path obscures ordinary diagnostics.
|
||||
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
|
||||
continue;
|
||||
}
|
||||
visible_diagnostics += 1;
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{severity} · {}:{}:{} · {}",
|
||||
"{}:{}:{} {}",
|
||||
issue.path,
|
||||
issue.diagnostic.line,
|
||||
issue.diagnostic.column,
|
||||
issue.diagnostic.message,
|
||||
issue.diagnostic.message
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||||
.color(match issue.diagnostic.severity {
|
||||
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
|
||||
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
|
||||
}),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.text_diagnostic_message(location)),
|
||||
.on_press(Message::SourceDiagnostic(index)),
|
||||
);
|
||||
for related in &issue.related {
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!("Related declaration · {}", related.path))
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 12])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.text_diagnostic_message(related.clone())),
|
||||
);
|
||||
diagnostics = diagnostics.push(action(
|
||||
&format!("Related declaration · {}", related.path),
|
||||
Message::SourceRelated(related.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let guard = crate::diagnostic_input::Guard::new(
|
||||
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
|
||||
diagnostics,
|
||||
);
|
||||
text_workspace::view(app, source_symbols::panel(app), guard.into())
|
||||
}
|
||||
|
||||
pub(crate) fn source_binding(
|
||||
press: widget::text_editor::KeyPress,
|
||||
context: &crate::source_navigation::SymbolContext,
|
||||
rename: Option<&crate::app::source_rename::Begin>,
|
||||
) -> Option<widget::text_editor::Binding<Message>> {
|
||||
use crate::source_navigation::{SymbolEvent, query::Action};
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
use widget::text_editor::{Binding, Status};
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers.is_empty()
|
||||
&& press.key == Key::Named(Named::F2)
|
||||
&& let Some(begin) = rename
|
||||
{
|
||||
return Some(Binding::Custom(Message::SourceRename(
|
||||
crate::app::source_rename::Event::Begin(begin.clone()),
|
||||
)));
|
||||
}
|
||||
// Windows owns Alt+Space for its window menu. Iced's default binding would
|
||||
// otherwise insert a supplied space payload, even after that menu closes.
|
||||
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers == iced::keyboard::Modifiers::ALT
|
||||
&& matches!(
|
||||
press.key.as_ref(),
|
||||
Key::Named(Named::Space) | Key::Character(" ")
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& !press.modifiers.control()
|
||||
&& !press.modifiers.logo()
|
||||
{
|
||||
let event = match &press.key {
|
||||
Key::Named(Named::F12) if !press.modifiers.alt() => {
|
||||
Some(SymbolEvent::Query(if press.modifiers.shift() {
|
||||
Action::References
|
||||
} else {
|
||||
Action::Definition
|
||||
}))
|
||||
}
|
||||
Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => {
|
||||
Some(SymbolEvent::Back)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
return Some(Binding::Custom(Message::SourceSymbols(
|
||||
context.clone(),
|
||||
event,
|
||||
)));
|
||||
}
|
||||
}
|
||||
Binding::from_key_press(press)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
pub(crate) mod source_tests {
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
|
||||
};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
|
||||
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
|
||||
pub(crate) struct FontLayout;
|
||||
impl renderer::Renderer for FontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(
|
||||
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut (), handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for FontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = graphics::text::Paragraph;
|
||||
type Editor = graphics::text::Editor;
|
||||
const ICON_FONT: Font = Font::DEFAULT;
|
||||
const CHECKMARK_ICON: char = '0';
|
||||
const ARROW_DOWN_ICON: char = '0';
|
||||
const SCROLL_UP_ICON: char = '0';
|
||||
const SCROLL_DOWN_ICON: char = '0';
|
||||
const SCROLL_LEFT_ICON: char = '0';
|
||||
const SCROLL_RIGHT_ICON: char = '0';
|
||||
const ICED_LOGO: char = '0';
|
||||
fn default_font(&self) -> Font {
|
||||
Font::DEFAULT
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
Pixels(16.)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
|
||||
use core_widget::operation::Focusable;
|
||||
use iced::keyboard::{
|
||||
self, Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
|
||||
let baseline = "// is excluded\r\n// 雨\r\n";
|
||||
let context = crate::source_navigation::SymbolContext {
|
||||
session: Id::new_v4(),
|
||||
path: "救援/assess.arcadia".into(),
|
||||
};
|
||||
for (key, physical_key, modifiers, payload, inserted) in [
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT,
|
||||
" ",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Key::Character(" ".into()),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT,
|
||||
" ",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::empty(),
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Character("e".into()),
|
||||
Physical::Code(Code::KeyE),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
"€",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT | Modifiers::SHIFT,
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let mut content = Content::<FontLayout>::with_text(baseline);
|
||||
content.move_to(widget::text_editor::Cursor {
|
||||
position: widget::text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
let press = KeyPress {
|
||||
key: key.clone(),
|
||||
modified_key: key.clone(),
|
||||
physical_key,
|
||||
modifiers,
|
||||
text: Some(payload.into()),
|
||||
status: Status::Focused { is_hovered: false },
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
Binding::<Message>::from_key_press(press),
|
||||
Some(Binding::Insert(_))
|
||||
),
|
||||
"the supplied event must expose the default insertion path"
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
{
|
||||
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
|
||||
widget::text_editor(&content)
|
||||
let editor = widget::text_editor(&app.source_editor)
|
||||
.id("source-editor")
|
||||
.on_action(Message::SourceEdit)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Fill)
|
||||
.key_binding(|press| source_binding(press, &context, None))
|
||||
.into();
|
||||
let mut tree = core_widget::Tree::new(editor.as_widget());
|
||||
let size = Size::new(600., 240.);
|
||||
let node = editor.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, size),
|
||||
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
||||
app.source_snapshot.clone(),
|
||||
crate::highlight::format,
|
||||
);
|
||||
tree.state
|
||||
.downcast_mut::<State<shaped::highlighter::PlainText>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: key.clone(),
|
||||
modified_key: key,
|
||||
physical_key,
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: Some(payload.into()),
|
||||
repeat: false,
|
||||
});
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
editor.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
usize::from(inserted),
|
||||
"modifiers={modifiers:?}"
|
||||
);
|
||||
for message in messages {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("unexpected navigation action");
|
||||
};
|
||||
content.perform(action);
|
||||
}
|
||||
let expected = if inserted {
|
||||
format!("// is{payload} excluded\r\n// 雨\r\n")
|
||||
column![
|
||||
container(
|
||||
column![
|
||||
row![
|
||||
container(heading(
|
||||
"Textual model",
|
||||
"One model across files · comments and identities preserved"
|
||||
))
|
||||
.width(Fill),
|
||||
button(text("Apply all").size(12))
|
||||
.padding(10)
|
||||
.on_press(Message::ApplySource),
|
||||
action("Revert file", Message::RevertSource)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
action("Undo text", Message::SourceUndo),
|
||||
action("Redo text", Message::SourceRedo),
|
||||
button(text("Undo model").size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
|
||||
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
|
||||
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
|
||||
label(if app.source_dirty() {
|
||||
"Drafts pending"
|
||||
} else {
|
||||
baseline.into()
|
||||
};
|
||||
assert_eq!(content.text(), expected);
|
||||
}
|
||||
}
|
||||
"Model in sync"
|
||||
})
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center),
|
||||
recovery,
|
||||
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
|
||||
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
|
||||
"The previous graphical model remains available while these files are recovered."
|
||||
} else if !app.source_workspace.applied {
|
||||
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
|
||||
} else if app.source_workspace.detached_dirty() {
|
||||
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
|
||||
} else {
|
||||
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
|
||||
}),
|
||||
navigation,
|
||||
scrollable(diagnostics)
|
||||
.height((visible_diagnostics as f32 * 32.).min(132.))
|
||||
]
|
||||
.spacing(12)
|
||||
)
|
||||
.padding(18),
|
||||
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
|
||||
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn checks(app: &App) -> View<'_, Message> {
|
||||
let issues = &app.model_issues;
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
source_navigation::{
|
||||
SymbolEvent,
|
||||
query::{Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
},
|
||||
style,
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{button, column, container, scrollable, text, tooltip},
|
||||
};
|
||||
|
||||
pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::text_workspace::{DrawerTab, Event};
|
||||
use std::collections::BTreeSet;
|
||||
let state = &app.source_symbols;
|
||||
let visible = app.text_workspace.drawer() == Some(DrawerTab::References);
|
||||
let presentation = app.text_workspace.references();
|
||||
let mut content = column![].spacing(0).width(Fill);
|
||||
if visible && let Some(notice) = &state.notice {
|
||||
content = content.push(text(notice.clone()).size(12));
|
||||
}
|
||||
if visible && let Some(results) = &state.results {
|
||||
let stale = results.stamp.snapshot != app.source_key();
|
||||
let enabled = !stale && app.source_navigation_available();
|
||||
let files: BTreeSet<_> = results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.chain(results.references.iter().map(|row| row.path.as_str()))
|
||||
.collect();
|
||||
let mut ordered: Vec<_> = files.iter().copied().collect();
|
||||
// Keep the query's captured origin first even after navigating elsewhere.
|
||||
if let Some(origin) = state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| request.path.as_str())
|
||||
{
|
||||
ordered.sort_by_key(|path| (*path != origin, *path));
|
||||
}
|
||||
for path in ordered {
|
||||
let collapsed = presentation.collapsed(path);
|
||||
let count = results
|
||||
.definitions
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count()
|
||||
+ results
|
||||
.references
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count();
|
||||
let suffix = distinguishing_path(path, &files);
|
||||
content = content.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{} {suffix} · {count}",
|
||||
if collapsed { "▸" } else { "▾" }
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.height(24)
|
||||
.width(Fill)
|
||||
.padding([2, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.source_navigation_available().then(|| {
|
||||
Message::TextWorkspace(Event::ToggleFile {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path: path.to_owned(),
|
||||
})
|
||||
})),
|
||||
);
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
for (index, definition) in results
|
||||
.definitions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
content = content.push(location_row(
|
||||
declaration_title(&definition.declaration),
|
||||
path,
|
||||
definition.position,
|
||||
provenance(
|
||||
definition.membership,
|
||||
definition.evidence,
|
||||
definition.declaration.identity,
|
||||
false,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: false,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
for (index, reference) in results
|
||||
.references
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
let title = reference.context.as_ref().map_or_else(
|
||||
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|
||||
|context| format!("use · {}", declaration_title(context)),
|
||||
);
|
||||
content = content.push(location_row(
|
||||
title,
|
||||
path,
|
||||
reference.position,
|
||||
provenance(
|
||||
reference.membership,
|
||||
reference.evidence,
|
||||
reference
|
||||
.context
|
||||
.as_ref()
|
||||
.map_or(Identity::Missing, |context| context.identity),
|
||||
true,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: true,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
// Full path is readable by ordinary scrolling, independently of hover.
|
||||
content = content.push(
|
||||
container(
|
||||
text(path.to_owned())
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.padding([3, 8]),
|
||||
);
|
||||
}
|
||||
let scope = &results.scope;
|
||||
let explanation = match results.disposition {
|
||||
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
|
||||
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
|
||||
Disposition::Choices => {
|
||||
"Choose a declaration; draft and detached candidates need an explicit choice."
|
||||
}
|
||||
Disposition::Direct => "Definition in the current sources.",
|
||||
Disposition::LexicalReferences => {
|
||||
"Lexical uses of this alias; these do not establish a semantic target."
|
||||
}
|
||||
};
|
||||
content = content.push(text(explanation).size(12));
|
||||
if stale {
|
||||
content = content.push(
|
||||
text("Source changed. Run Definition or References again for current locations.")
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if results.total_definitions > 1 {
|
||||
content = content.push(
|
||||
text("Alias collision: uses are not attributed to a single declaration.").size(12),
|
||||
);
|
||||
}
|
||||
content = content
|
||||
.push(
|
||||
text(format!(
|
||||
"{} · {} included / {} detached / {} unverified files · {} scope issues",
|
||||
if scope.partial() {
|
||||
"Partial include scope"
|
||||
} else {
|
||||
"Complete include scope"
|
||||
},
|
||||
scope.included_files,
|
||||
scope.detached_files,
|
||||
scope.unverified_files,
|
||||
scope.issues.len()
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(if !scope.applied {
|
||||
"Unapplied drafts"
|
||||
} else if scope.validated {
|
||||
"Validated sources"
|
||||
} else {
|
||||
"Draft sources"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Declarations",
|
||||
results.definitions.len(),
|
||||
results.total_definitions,
|
||||
results.omitted_definitions,
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Lexical uses",
|
||||
results.references.len(),
|
||||
results.total_references,
|
||||
results.omitted_references,
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let identity = if visible {
|
||||
state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| (request.job, app.text_workspace.drawer_visit()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let guarded = crate::diagnostic_input::Guard::new(identity, content);
|
||||
let mut body = scrollable(
|
||||
container(guarded)
|
||||
.width(Fill)
|
||||
.padding(iced::Padding::ZERO.right(14)),
|
||||
)
|
||||
.id(app.text_workspace.reference_scroll_id())
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
if let Some(stamp) = presentation.stamp() {
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
body = body.on_scroll(move |viewport| {
|
||||
Message::TextWorkspace(Event::ReferenceScrolled {
|
||||
stamp,
|
||||
visit,
|
||||
y: viewport.absolute_offset().y,
|
||||
})
|
||||
});
|
||||
}
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// Shortest suffix that still distinguishes duplicate basenames and suffix paths.
|
||||
fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str {
|
||||
let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1));
|
||||
for start in starts.collect::<Vec<_>>().into_iter().rev() {
|
||||
let candidate = &path[start..];
|
||||
if files
|
||||
.iter()
|
||||
.filter(|other| {
|
||||
**other == candidate
|
||||
|| other
|
||||
.strip_suffix(candidate)
|
||||
.is_some_and(|prefix| prefix.ends_with('/'))
|
||||
})
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
|
||||
format!("{label}: {total} total · {shown} shown · {omitted} omitted")
|
||||
}
|
||||
|
||||
fn declaration_title(declaration: &Declaration) -> String {
|
||||
let name = declaration
|
||||
.name
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48)));
|
||||
format!(
|
||||
"{}{} · @{}",
|
||||
preview(&declaration.kind, 24),
|
||||
name,
|
||||
preview(&declaration.alias, 48)
|
||||
)
|
||||
}
|
||||
|
||||
fn provenance(
|
||||
membership: Membership,
|
||||
evidence: Evidence,
|
||||
identity: Identity,
|
||||
context: bool,
|
||||
) -> String {
|
||||
let membership = match membership {
|
||||
Membership::Included => "Included",
|
||||
Membership::Detached => "Detached",
|
||||
Membership::Unverified => "Unverified",
|
||||
};
|
||||
let evidence = match evidence {
|
||||
Evidence::Validated => "Validated",
|
||||
Evidence::Draft => "Draft",
|
||||
};
|
||||
let collision = match identity {
|
||||
Identity::Collision { count } => format!(
|
||||
" · {}ID collision ({count})",
|
||||
if context { "Context " } else { "" },
|
||||
),
|
||||
Identity::Missing | Identity::Unique(_) => String::new(),
|
||||
};
|
||||
format!("{membership} · {evidence}{collision}")
|
||||
}
|
||||
|
||||
fn location_row(
|
||||
title: String,
|
||||
path: &str,
|
||||
position: Position,
|
||||
status: String,
|
||||
event: Option<Message>,
|
||||
) -> Element<'static, Message> {
|
||||
let line = position.line.saturating_add(1);
|
||||
let column = position.byte_column.saturating_add(1);
|
||||
let full =
|
||||
format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)");
|
||||
tooltip(
|
||||
button(
|
||||
container(
|
||||
text(format!("{line}:{column} {title} · {status}"))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.clip(true)
|
||||
.width(Fill),
|
||||
)
|
||||
.padding([3, 12])
|
||||
.height(28)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press_maybe(event),
|
||||
text(full).size(12),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep headings compact without copying the remainder of a long admitted value.
|
||||
fn preview(value: &str, limit: usize) -> String {
|
||||
let mut chars = value.chars();
|
||||
let mut result: String = chars
|
||||
.by_ref()
|
||||
.take(limit)
|
||||
.map(|ch| if ch.is_whitespace() { ' ' } else { ch })
|
||||
.collect();
|
||||
if chars.next().is_some() {
|
||||
result.push('…');
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() {
|
||||
let files = [
|
||||
"a/core.arcadia",
|
||||
"b/core.arcadia",
|
||||
"core.arcadia",
|
||||
"救援/actors.arcadia",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
distinguishing_path("a/core.arcadia", &files),
|
||||
"a/core.arcadia"
|
||||
);
|
||||
assert_eq!(
|
||||
distinguishing_path("b/core.arcadia", &files),
|
||||
"b/core.arcadia"
|
||||
);
|
||||
assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia");
|
||||
assert_eq!(
|
||||
distinguishing_path("救援/actors.arcadia", &files),
|
||||
"actors.arcadia"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,248 +0,0 @@
|
||||
//! Shapes and draws the production summary, checking actual paragraph advances.
|
||||
//! Native validation remains necessary for platform raster ink/fallback fonts.
|
||||
use super::*;
|
||||
use crate::source_navigation::query;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Cluster {
|
||||
paragraph: String,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
struct Recorder {
|
||||
clips: Vec<Rectangle>,
|
||||
transforms: Vec<Transformation>,
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
impl Recorder {
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for Recorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.clips = vec![bounds];
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clusters.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for Recorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let content = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
self.clusters.push(Cluster {
|
||||
paragraph: content.clone(),
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds: Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform(),
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"];
|
||||
|
||||
fn draw_and_check_flags(
|
||||
content: Element<'_, Message, iced::Theme, Recorder>,
|
||||
width: f32,
|
||||
) -> Vec<String> {
|
||||
let viewport = Rectangle::with_size(Size::new(width, 24.));
|
||||
let mut renderer = Recorder {
|
||||
clips: vec![viewport],
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clusters: Vec::new(),
|
||||
};
|
||||
let mut view: Element<'_, Message, iced::Theme, Recorder> =
|
||||
container(content).width(Fill).height(24).clip(true).into();
|
||||
let mut tree = Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
assert_eq!(node.size(), viewport.size());
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for flag in FLAGS {
|
||||
for byte in 0..flag.len() {
|
||||
let matching: Vec<_> = renderer
|
||||
.clusters
|
||||
.iter()
|
||||
.filter(|cluster| {
|
||||
cluster
|
||||
.paragraph
|
||||
.find(flag)
|
||||
.is_some_and(|start| cluster.bytes.contains(&(start + byte)))
|
||||
})
|
||||
.collect();
|
||||
if matching.is_empty() {
|
||||
failures.push(format!("missing shaped flag byte: {flag}[{byte}]"));
|
||||
}
|
||||
for cluster in matching {
|
||||
let b = cluster.bounds;
|
||||
let c = cluster.clip;
|
||||
if !(b.x >= c.x - 0.5
|
||||
&& b.y >= c.y - 0.5
|
||||
&& b.x + b.width <= c.x + c.width + 0.5
|
||||
&& b.y + b.height <= c.y + c.height + 0.5)
|
||||
{
|
||||
failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> {
|
||||
text(value)
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::None)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() {
|
||||
let mut app = App::with_project(oa_core::Project::empty());
|
||||
let old_key = App::with_project(oa_core::Project::empty()).source_key();
|
||||
assert_ne!(old_key, app.source_key());
|
||||
for alias in ["雨".repeat(32), "W".repeat(32)] {
|
||||
// Unicode aliases are presentation stress: the current lexer admits
|
||||
// ASCII identifiers only. Six-digit totals are within the query's
|
||||
// 250,000-record cap; this does not build that many source records.
|
||||
let old_value = format!(
|
||||
"@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified"
|
||||
);
|
||||
app.source_symbols.results = Some(Arc::new(query::Results {
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: old_key,
|
||||
serial: 1,
|
||||
},
|
||||
action: query::Action::References,
|
||||
origin_path: app.source_workspace.active.clone(),
|
||||
origin_offset: 0,
|
||||
origin: None,
|
||||
alias: Some(alias.clone()),
|
||||
scope: query::Scope {
|
||||
closure_complete: false,
|
||||
applied: false,
|
||||
validated: false,
|
||||
included_files: 28,
|
||||
detached_files: 1,
|
||||
unverified_files: 1,
|
||||
issues: Vec::new(),
|
||||
},
|
||||
disposition: query::Disposition::LexicalReferences,
|
||||
definitions: Vec::new(),
|
||||
references: Vec::new(),
|
||||
total_definitions: 100_000,
|
||||
total_references: 100_000,
|
||||
omitted_definitions: 100_000,
|
||||
omitted_references: 100_000,
|
||||
}));
|
||||
for width in [840., 720.] {
|
||||
let positive = draw_and_check_flags(reference_summary(&app), width);
|
||||
assert!(
|
||||
positive.is_empty(),
|
||||
"reserved flags, width {width}, alias {alias:?}: {positive:?}"
|
||||
);
|
||||
// Observe the exact old presentation without predicting font widths.
|
||||
// This is emitted under --nocapture, whether it clips or fits.
|
||||
let old = draw_and_check_flags(old_summary(old_value.clone()), width);
|
||||
println!(
|
||||
"old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}",
|
||||
old.len(),
|
||||
old.first()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Deliberate evaluator stress, beyond the production 32-character subject
|
||||
// bound: require the same evaluator to reject a clipped old-style paragraph.
|
||||
// This is not evidence of a production/native overflow at 840 pixels.
|
||||
let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · "));
|
||||
let rejected = draw_and_check_flags(old_summary(stress), 720.);
|
||||
assert!(
|
||||
!rejected.is_empty(),
|
||||
"negative control must detect missing/clipped warning text"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "54abf81d13fec06d4d9ac754b03ce2c3313e8a1f"
|
||||
},
|
||||
"path_in_vcs": "widget"
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
# Pinned Iced widget patch
|
||||
|
||||
Upstream crate: iced_widget 0.14.2, MIT. Its registry VCS record identifies
|
||||
`iced-rs/iced` commit `54abf81d13fec06d4d9ac754b03ce2c3313e8a1f`, path `widget`.
|
||||
The accompanying LICENSE was obtained from that exact upstream commit:
|
||||
https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c3313e8a1f/LICENSE
|
||||
SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`.
|
||||
|
||||
All copied src/assets and Cargo manifests retain their upstream bytes except
|
||||
`src/text_editor.rs`, which adds one documented read-only `Content::with_editor`
|
||||
callback. Original file SHA-256:
|
||||
`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`.
|
||||
The callback borrows the existing renderer editor and releases the borrow when
|
||||
the closure returns. It adds no mutable editor access or event behavior.
|
||||
|
||||
ARCADIA uses this seam to project source diagnostics onto the actual painted
|
||||
editor layout. It must not clone/rebuild Content or move user selection for
|
||||
measurement. The isolated Windows CPU experiment verified the exact painted
|
||||
editor identity and geometry plus retained Word/Line selection and scroll;
|
||||
application integration/native validation are separately required.
|
||||
|
||||
UPSTREAM-MANIFEST.json records all files from the local pinned registry source,
|
||||
including registry bookkeeping and the upstream crate lockfile not copied here.
|
||||
Those omitted files are not package inputs. The workspace root Cargo.lock and
|
||||
local patch path identify the resolved dependency; this patched package is not
|
||||
the unmodified registry checksum. Keep LICENSE and this provenance in source
|
||||
captures and generated dependency notices.
|
||||
|
||||
On upgrades compare the pristine upstream module and replay only this accessor.
|
||||
Recheck actual editor layout, selection/input continuity and annotation tests.
|
||||
Remove this patch if upstream provides equivalent read-only layout access.
|
||||
Vendored
-126
@@ -1,126 +0,0 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "iced_widget"
|
||||
version = "0.14.2"
|
||||
authors = ["Héctor Ramón Jiménez <hector@hecrj.dev>"]
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "The built-in widgets for iced"
|
||||
homepage = "https://iced.rs"
|
||||
readme = false
|
||||
keywords = [
|
||||
"gui",
|
||||
"ui",
|
||||
"graphics",
|
||||
"interface",
|
||||
"widgets",
|
||||
]
|
||||
categories = ["gui"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/iced-rs/iced"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = [
|
||||
"--cfg",
|
||||
"docsrs",
|
||||
]
|
||||
all-features = true
|
||||
|
||||
[features]
|
||||
advanced = []
|
||||
canvas = ["iced_renderer/geometry"]
|
||||
crisp = []
|
||||
highlighter = ["dep:iced_highlighter"]
|
||||
image = ["iced_renderer/image"]
|
||||
lazy = ["ouroboros"]
|
||||
markdown = ["dep:pulldown-cmark"]
|
||||
qr_code = [
|
||||
"canvas",
|
||||
"dep:qrcode",
|
||||
]
|
||||
svg = ["iced_renderer/svg"]
|
||||
wgpu = ["iced_renderer/wgpu-bare"]
|
||||
|
||||
[lib]
|
||||
name = "iced_widget"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies.iced_highlighter]
|
||||
version = "0.14.0"
|
||||
optional = true
|
||||
|
||||
[dependencies.iced_renderer]
|
||||
version = "0.14.0"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.num-traits]
|
||||
version = "0.2"
|
||||
|
||||
[dependencies.ouroboros]
|
||||
version = "0.18"
|
||||
optional = true
|
||||
|
||||
[dependencies.pulldown-cmark]
|
||||
version = "0.12"
|
||||
optional = true
|
||||
|
||||
[dependencies.qrcode]
|
||||
version = "0.13"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.rustc-hash]
|
||||
version = "2.0"
|
||||
|
||||
[dependencies.thiserror]
|
||||
version = "2"
|
||||
|
||||
[dependencies.unicode-segmentation]
|
||||
version = "1.0"
|
||||
|
||||
[lints.clippy]
|
||||
default_trait_access = "deny"
|
||||
filter_map_next = "deny"
|
||||
from_over_into = "deny"
|
||||
large-enum-variant = "allow"
|
||||
manual_let_else = "deny"
|
||||
map-entry = "allow"
|
||||
match-wildcard-for-single-variants = "deny"
|
||||
needless_borrow = "deny"
|
||||
new_without_default = "deny"
|
||||
redundant-closure-for-method-calls = "deny"
|
||||
result_large_err = "allow"
|
||||
semicolon_if_nothing_returned = "deny"
|
||||
trivially-copy-pass-by-ref = "deny"
|
||||
type-complexity = "allow"
|
||||
unused_async = "deny"
|
||||
useless_conversion = "deny"
|
||||
|
||||
[lints.rust]
|
||||
missing_docs = "deny"
|
||||
unsafe_code = "deny"
|
||||
unused_results = "deny"
|
||||
|
||||
[lints.rust.rust_2018_idioms]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[lints.rustdoc]
|
||||
broken_intra_doc_links = "forbid"
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
[package]
|
||||
name = "iced_widget"
|
||||
description = "The built-in widgets for iced"
|
||||
version = "0.14.2"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
categories.workspace = true
|
||||
keywords.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
all-features = true
|
||||
|
||||
[features]
|
||||
lazy = ["ouroboros"]
|
||||
image = ["iced_renderer/image"]
|
||||
svg = ["iced_renderer/svg"]
|
||||
canvas = ["iced_renderer/geometry"]
|
||||
qr_code = ["canvas", "dep:qrcode"]
|
||||
wgpu = ["iced_renderer/wgpu-bare"]
|
||||
markdown = ["dep:pulldown-cmark"]
|
||||
highlighter = ["dep:iced_highlighter"]
|
||||
advanced = []
|
||||
crisp = []
|
||||
|
||||
[dependencies]
|
||||
iced_renderer.workspace = true
|
||||
|
||||
num-traits.workspace = true
|
||||
log.workspace = true
|
||||
rustc-hash.workspace = true
|
||||
thiserror.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
|
||||
ouroboros.workspace = true
|
||||
ouroboros.optional = true
|
||||
|
||||
qrcode.workspace = true
|
||||
qrcode.optional = true
|
||||
|
||||
pulldown-cmark.workspace = true
|
||||
pulldown-cmark.optional = true
|
||||
|
||||
iced_highlighter.workspace = true
|
||||
iced_highlighter.optional = true
|
||||
Vendored
-18
@@ -1,18 +0,0 @@
|
||||
Copyright 2019 Héctor Ramón, Iced contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
-352
@@ -1,352 +0,0 @@
|
||||
[
|
||||
{
|
||||
"path": ".cargo-ok",
|
||||
"bytes": 7,
|
||||
"sha256": "afbf9d0f3560b0fd7795e81c42a0a79ee6b6fc67e064f77826aee642cad28d91"
|
||||
},
|
||||
{
|
||||
"path": ".cargo_vcs_info.json",
|
||||
"bytes": 100,
|
||||
"sha256": "58c111ee6197295ae381d021ab7afd7ac38b02acce90d0ff0f82ae2e1e5f864c"
|
||||
},
|
||||
{
|
||||
"path": "assets/iced-logo.svg",
|
||||
"bytes": 1892,
|
||||
"sha256": "8ac9e30de1b466b3d594c77c984299b1a229f6f0f010e6804139a2f41ea8345e"
|
||||
},
|
||||
{
|
||||
"path": "Cargo.lock",
|
||||
"bytes": 56700,
|
||||
"sha256": "89fe393e9bbc1c6dc94796d764dfb3f864adb400607b4508b6138eb3205cce37"
|
||||
},
|
||||
{
|
||||
"path": "Cargo.toml",
|
||||
"bytes": 2622,
|
||||
"sha256": "e8e6b9a72fc0f97c84b8fb2703fe6cdccd738c6c01d2fadf018a012182de93b5"
|
||||
},
|
||||
{
|
||||
"path": "Cargo.toml.orig",
|
||||
"bytes": 1109,
|
||||
"sha256": "f9bed37f4aa1edebea47824c1f9678411d410a1546401a14ce6f8b4108c96448"
|
||||
},
|
||||
{
|
||||
"path": "src/action.rs",
|
||||
"bytes": 2507,
|
||||
"sha256": "feb91a386073280d76eaf37dbb8e7380e5a6bf9b8003110107b32bb8f577a239"
|
||||
},
|
||||
{
|
||||
"path": "src/button.rs",
|
||||
"bytes": 21150,
|
||||
"sha256": "a8019fee3ad691fb2395afcdf302bec5f9e1cc592552aff4d8c8bbd1c6149f5a"
|
||||
},
|
||||
{
|
||||
"path": "src/canvas/program.rs",
|
||||
"bytes": 3099,
|
||||
"sha256": "637bbc6d10a623f355c6d44af1162a10331861806cec094e2a8f2a97313acab9"
|
||||
},
|
||||
{
|
||||
"path": "src/canvas.rs",
|
||||
"bytes": 9637,
|
||||
"sha256": "1fecfd50d593c5ff9bc9317c9f1624d0c22eddc7cf39a795684bf70eb5954acd"
|
||||
},
|
||||
{
|
||||
"path": "src/checkbox.rs",
|
||||
"bytes": 20973,
|
||||
"sha256": "4409c11d6473baf4baefafbb3ed253cd9353f04136cac6009422a876a84ec6b1"
|
||||
},
|
||||
{
|
||||
"path": "src/column.rs",
|
||||
"bytes": 17490,
|
||||
"sha256": "f880d15ebdb33e2f6ebb6067bb0b124c8fafdc73a6e5112a6f5b5e012fddadf4"
|
||||
},
|
||||
{
|
||||
"path": "src/combo_box.rs",
|
||||
"bytes": 30694,
|
||||
"sha256": "973901d05003f684be1d13da5adc88ef7e030dc31db07c1bb0717cf9210d2391"
|
||||
},
|
||||
{
|
||||
"path": "src/container.rs",
|
||||
"bytes": 18126,
|
||||
"sha256": "f8162a4e1c25c602070da482ecccfd5e62f456ffe1caa5394a11243d339f6114"
|
||||
},
|
||||
{
|
||||
"path": "src/float.rs",
|
||||
"bytes": 12092,
|
||||
"sha256": "9bc2d313052c97338dcdec2a5be7e0301b6261fe7975b511050fe4abeeb1c571"
|
||||
},
|
||||
{
|
||||
"path": "src/grid.rs",
|
||||
"bytes": 11862,
|
||||
"sha256": "01e4deea4638fba19e3859d7ae27bd8090bca3fcee6f31c48bf5ad9a19f5c151"
|
||||
},
|
||||
{
|
||||
"path": "src/helpers.rs",
|
||||
"bytes": 60874,
|
||||
"sha256": "3989cad7e901ff8171a16024d93ec40cb32e7859c2e76a98359f5f230a2fabcb"
|
||||
},
|
||||
{
|
||||
"path": "src/image/viewer.rs",
|
||||
"bytes": 14066,
|
||||
"sha256": "ce372e8ba97248cea915abd52046020c0f5c318efc2af1d7f4d74958b14629bf"
|
||||
},
|
||||
{
|
||||
"path": "src/image.rs",
|
||||
"bytes": 12361,
|
||||
"sha256": "dab4c761c846ba2006ba5298c9b3573b0a2787272b2f74376cde6c35ae7f3694"
|
||||
},
|
||||
{
|
||||
"path": "src/keyed/column.rs",
|
||||
"bytes": 10710,
|
||||
"sha256": "09adcae868d561443e8d8b6f3cdfefd6831d8e94adf2b22c551c85b9669eb620"
|
||||
},
|
||||
{
|
||||
"path": "src/keyed.rs",
|
||||
"bytes": 2840,
|
||||
"sha256": "f1e572a13d0717816a6d4467d14f07fa613956a6bf9a2b234d79789b0bcb1d57"
|
||||
},
|
||||
{
|
||||
"path": "src/lazy/cache.rs",
|
||||
"bytes": 377,
|
||||
"sha256": "59445853fb59941d73904689e5ee34bc6e9f7159c58bbea0cac8ef87f69a1b10"
|
||||
},
|
||||
{
|
||||
"path": "src/lazy/component.rs",
|
||||
"bytes": 20440,
|
||||
"sha256": "05e4e793f3462e5f164ef1191a5cf2c6daf32d410aeebbe1bb4e951fb04fdef7"
|
||||
},
|
||||
{
|
||||
"path": "src/lazy/helpers.rs",
|
||||
"bytes": 1292,
|
||||
"sha256": "d6bab15685469619bc3833afacc1df22112adacbdf944d6dbb5ea1b9e5f9d993"
|
||||
},
|
||||
{
|
||||
"path": "src/lazy.rs",
|
||||
"bytes": 11530,
|
||||
"sha256": "8c127fcd044cf887564856ce3a38ecf41ff3ff24d832d72095ec4e92f9907673"
|
||||
},
|
||||
{
|
||||
"path": "src/lib.rs",
|
||||
"bytes": 2933,
|
||||
"sha256": "4da7f1a83cd8edc30bd9983e59ba3537f44a475814d0b9c80dcf7e97873badb4"
|
||||
},
|
||||
{
|
||||
"path": "src/markdown.rs",
|
||||
"bytes": 50899,
|
||||
"sha256": "5269582001d26d79f9de4e6ee60ebed587adf552f6f91c8ffcd757782319d161"
|
||||
},
|
||||
{
|
||||
"path": "src/mouse_area.rs",
|
||||
"bytes": 12666,
|
||||
"sha256": "01686e65f76a4b89356ec6e5900b4411c856b15a56236ed3f6ef410ec3a4983d"
|
||||
},
|
||||
{
|
||||
"path": "src/overlay/menu.rs",
|
||||
"bytes": 19043,
|
||||
"sha256": "89719b05b75b4b1382de49e1220cab0034b155a24d744509ad928a25d69cca57"
|
||||
},
|
||||
{
|
||||
"path": "src/overlay.rs",
|
||||
"bytes": 72,
|
||||
"sha256": "104d339ba61626ebf0877f5c942e9f67b3db6261d6c9ae5e4805859508e1bc49"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/axis.rs",
|
||||
"bytes": 7860,
|
||||
"sha256": "4e7a842bc60bee64ee86c4d512c0ac034e4c6bf16b1fe0777c16b8a0b5b28751"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/configuration.rs",
|
||||
"bytes": 609,
|
||||
"sha256": "0d70a26e2fcafc1f1403e43f6c0790698940c92a0966d4ea4acec216778d65cd"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/content.rs",
|
||||
"bytes": 11886,
|
||||
"sha256": "13ec6f9517e0cbd7d2a311ef57b963eff2c5fe6f05264cca29e54d14a0ea8151"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/controls.rs",
|
||||
"bytes": 1591,
|
||||
"sha256": "c7eeffce253b8aa0e7853baaf33ab3e926261df7e6b809a566aa17a4e779d560"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/direction.rs",
|
||||
"bytes": 186,
|
||||
"sha256": "f79259afb26fb38f4af7efdaea5fc6527b5426fe195ef89f72d16405d0bc3076"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/draggable.rs",
|
||||
"bytes": 290,
|
||||
"sha256": "3688ee1a543a281c036d2f4f156a3c529263219b50e3945ef50fa4bb63b02ae3"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/node.rs",
|
||||
"bytes": 10452,
|
||||
"sha256": "bcda8a561581849163a3bb75f34d310d6463ff58ea6660e9fa33c8ea22d40c9f"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/pane.rs",
|
||||
"bytes": 209,
|
||||
"sha256": "2305c36971b405368a99394ad6b3cd0cf21638d9933f8cbd445fd764a257a4df"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/split.rs",
|
||||
"bytes": 221,
|
||||
"sha256": "8be472fc48c37055bbb62395839c599559abc3d70cb7806892f869bd95e90804"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/state.rs",
|
||||
"bytes": 14109,
|
||||
"sha256": "a13125af64ea6da1bcb5a13469f4ffdd51106776b27871e10ce3ac037d865b63"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid/title_bar.rs",
|
||||
"bytes": 20398,
|
||||
"sha256": "7556b48c85811d64e3c11c4ce901c579568f4ec056ff5f1bd5e38066b2f059ce"
|
||||
},
|
||||
{
|
||||
"path": "src/pane_grid.rs",
|
||||
"bytes": 42574,
|
||||
"sha256": "4eb5ae3be82237f5193b550d407c1a5a9f099a4d554442dbe8f51b0b72edb213"
|
||||
},
|
||||
{
|
||||
"path": "src/pick_list.rs",
|
||||
"bytes": 27489,
|
||||
"sha256": "449fbbc5342c2bed227ec2587111ab43dcecb7382001fbcba2fab2651e54b33b"
|
||||
},
|
||||
{
|
||||
"path": "src/pin.rs",
|
||||
"bytes": 7207,
|
||||
"sha256": "f0b0ab2089300c9ceab8f8f3c75cd96409c7a84f3fa26d68645aab3f92247426"
|
||||
},
|
||||
{
|
||||
"path": "src/progress_bar.rs",
|
||||
"bytes": 8776,
|
||||
"sha256": "38e9bb585a9b764ff6cb947e17d65259a6a32cf3a69b23844e4c28e6c5a1c3ba"
|
||||
},
|
||||
{
|
||||
"path": "src/qr_code.rs",
|
||||
"bytes": 12261,
|
||||
"sha256": "712482a35eab47e0559fafd5452a2b934c5fbc5ae07c14c0c71781713b4dbfcc"
|
||||
},
|
||||
{
|
||||
"path": "src/radio.rs",
|
||||
"bytes": 15482,
|
||||
"sha256": "7b0f42744416e07f8af9409c5bae997b07a0653492ccc2e71911bdeed9c747b2"
|
||||
},
|
||||
{
|
||||
"path": "src/responsive.rs",
|
||||
"bytes": 5358,
|
||||
"sha256": "2a3ff68573825a1407e3be27adbfb4851c7aae7cd100c7a0b670911bc3a08393"
|
||||
},
|
||||
{
|
||||
"path": "src/row.rs",
|
||||
"bytes": 16919,
|
||||
"sha256": "2566e8868bced1a6b7d455c9f1ab61c7025772b27bf843ee806d5c7806fcd44b"
|
||||
},
|
||||
{
|
||||
"path": "src/rule.rs",
|
||||
"bytes": 8599,
|
||||
"sha256": "b7b23722d314864d712e21c8ef260d2b747d0ac795bb3263a0a762c38467d6b9"
|
||||
},
|
||||
{
|
||||
"path": "src/scrollable.rs",
|
||||
"bytes": 80594,
|
||||
"sha256": "a176ea537bbe717550fe4dc17af31c801d9ec118edbcc44aab59ea1a78d1a7a2"
|
||||
},
|
||||
{
|
||||
"path": "src/sensor.rs",
|
||||
"bytes": 12155,
|
||||
"sha256": "cb95d1220fa5663b7a148d6b159081da2200debe6fea982a04f48e27dd072973"
|
||||
},
|
||||
{
|
||||
"path": "src/shader/program.rs",
|
||||
"bytes": 1761,
|
||||
"sha256": "4ce3711b2899b1c1d53544ff0c1f823e108542d356d5e374cb7b149c776d6918"
|
||||
},
|
||||
{
|
||||
"path": "src/shader.rs",
|
||||
"bytes": 5094,
|
||||
"sha256": "8753516e6a6b422a76632d07c716e047ff9b5e8f32bf9a9463c612c292306382"
|
||||
},
|
||||
{
|
||||
"path": "src/slider.rs",
|
||||
"bytes": 21695,
|
||||
"sha256": "778d6a4c37b334cba62ada5f4958e5909a91404961fe5c1104acd9dad12a18d5"
|
||||
},
|
||||
{
|
||||
"path": "src/space.rs",
|
||||
"bytes": 2436,
|
||||
"sha256": "40839bf1dfa93e02c9e3d7e2ab5bb41b328b1fe863846f6f1feca11d9885453b"
|
||||
},
|
||||
{
|
||||
"path": "src/stack.rs",
|
||||
"bytes": 12078,
|
||||
"sha256": "b12776ae1dd46ba95ca0ca52f6a095be1e0121c51f3180ad7cc202cd55764594"
|
||||
},
|
||||
{
|
||||
"path": "src/svg.rs",
|
||||
"bytes": 9618,
|
||||
"sha256": "a8167836a19806f4c2cc633a265953b8c4348f279082d7577b69f817e3166aeb"
|
||||
},
|
||||
{
|
||||
"path": "src/table.rs",
|
||||
"bytes": 20855,
|
||||
"sha256": "373f600db363090412e6bfc9c338bd6bc9516e78f7a43fb00f20d5ed21eb625c"
|
||||
},
|
||||
{
|
||||
"path": "src/text/rich.rs",
|
||||
"bytes": 16952,
|
||||
"sha256": "7deababcbe5f0294b605822fe2651eb0fee6e9c1bee37883dbc570481df6a0b9"
|
||||
},
|
||||
{
|
||||
"path": "src/text.rs",
|
||||
"bytes": 902,
|
||||
"sha256": "a7d125fc7d07ba51e05f86ca97f4ffad5db37ef2e6d4320bbcd8475d99d51ed3"
|
||||
},
|
||||
{
|
||||
"path": "src/text_editor.rs",
|
||||
"bytes": 48281,
|
||||
"sha256": "cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1"
|
||||
},
|
||||
{
|
||||
"path": "src/text_input/cursor.rs",
|
||||
"bytes": 5540,
|
||||
"sha256": "eeb9241a25110583fd2e169da47834aeb39fd55335ea3b77aa3d1e43016c28d8"
|
||||
},
|
||||
{
|
||||
"path": "src/text_input/editor.rs",
|
||||
"bytes": 1960,
|
||||
"sha256": "cd0e0168e9de1ad2599120a4dda8a5a373813a4ffc3a3c035929d18144544408"
|
||||
},
|
||||
{
|
||||
"path": "src/text_input/value.rs",
|
||||
"bytes": 4403,
|
||||
"sha256": "03fba71d34f2c31df15431ac7e22f854b655336f86ef854ae34cc1e0bfbcefdd"
|
||||
},
|
||||
{
|
||||
"path": "src/text_input.rs",
|
||||
"bytes": 61293,
|
||||
"sha256": "7cd64b0f79c6000f2985cc290642698a814be5dcd7b7a56ae463b13ffe5c3b47"
|
||||
},
|
||||
{
|
||||
"path": "src/themer.rs",
|
||||
"bytes": 8557,
|
||||
"sha256": "4b87e866b8f2a4420f30674ad64c605d24e3004f867bf95a1210b5338ed7a81e"
|
||||
},
|
||||
{
|
||||
"path": "src/toggler.rs",
|
||||
"bytes": 18223,
|
||||
"sha256": "70f1ef0ee8e2b6c0d7b93095eab1e615783bb81d945b4ff066af025dde8f5dcb"
|
||||
},
|
||||
{
|
||||
"path": "src/tooltip.rs",
|
||||
"bytes": 17259,
|
||||
"sha256": "7fcaa3899ad1a946bb1a51d34d344497005aaccdf825f510e98105d1bc40bc63"
|
||||
},
|
||||
{
|
||||
"path": "src/vertical_slider.rs",
|
||||
"bytes": 17741,
|
||||
"sha256": "75777345ced463b4a3b059fbe6c59efc952169987154b0597d87ce4da2076e3b"
|
||||
}
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140" fill="none" version="1.1" viewBox="35 31 179 171"><rect x="42" y="31.001" width="169.9" height="169.9" rx="49.815" fill="url(#paint1_linear)"/><path d="m182.62 65.747-28.136 28.606-6.13-6.0291 28.136-28.606 6.13 6.0291zm-26.344 0.218-42.204 42.909-6.13-6.029 42.204-42.909 6.13 6.0291zm-61.648 23.913c5.3254-5.3831 10.65-10.765 21.569-21.867l6.13 6.0291c-10.927 11.11-16.258 16.498-21.587 21.885-4.4007 4.4488-8.8009 8.8968-16.359 16.573l31.977 8.358 25.968-26.402 6.13 6.0292-25.968 26.402 8.907 31.908 42.138-42.087 6.076 6.083-49.109 49.05-45.837-12.628-13.394-45.646 1.7714-1.801c10.928-11.111 16.258-16.499 21.588-21.886zm28.419 70.99-8.846-31.689-31.831-8.32 9.1945 31.335 31.482 8.674zm47.734-56.517 7.122-7.1221-6.08-6.0797-7.147 7.1474-30.171 30.674 6.13 6.029 30.146-30.649z" clip-rule="evenodd" fill="url(#paint2_linear)" fill-rule="evenodd"/><defs><filter id="filter0_f" x="55" y="47.001" width="144" height="168" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur" stdDeviation="2"/></filter><linearGradient id="paint0_linear" x1="127" x2="127" y1="51.001" y2="211" gradientUnits="userSpaceOnUse"><stop offset=".052083"/><stop stop-opacity=".08" offset="1"/></linearGradient><linearGradient id="paint1_linear" x1="212" x2="57.5" y1="31.001" y2="189" gradientUnits="userSpaceOnUse"><stop stop-color="#00A3FF" offset="0"/><stop stop-color="#30f" offset="1"/></linearGradient><linearGradient id="paint2_linear" x1="86.098" x2="206.01" y1="158.28" y2="35.327" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#fff" offset="1"/></linearGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
-85
@@ -1,85 +0,0 @@
|
||||
use crate::core::event;
|
||||
use crate::core::time::Instant;
|
||||
use crate::core::window;
|
||||
|
||||
/// A runtime action that can be performed by some widgets.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Action<Message> {
|
||||
message_to_publish: Option<Message>,
|
||||
redraw_request: window::RedrawRequest,
|
||||
event_status: event::Status,
|
||||
}
|
||||
|
||||
impl<Message> Action<Message> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
message_to_publish: None,
|
||||
redraw_request: window::RedrawRequest::Wait,
|
||||
event_status: event::Status::Ignored,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new "capturing" [`Action`]. A capturing [`Action`]
|
||||
/// will make other widgets consider it final and prevent further
|
||||
/// processing.
|
||||
///
|
||||
/// Prevents "event bubbling".
|
||||
pub fn capture() -> Self {
|
||||
Self {
|
||||
event_status: event::Status::Captured,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`Action`] that publishes the given `Message` for
|
||||
/// the application to handle.
|
||||
///
|
||||
/// Publishing a `Message` always produces a redraw.
|
||||
pub fn publish(message: Message) -> Self {
|
||||
Self {
|
||||
message_to_publish: Some(message),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`Action`] that requests a redraw to happen as
|
||||
/// soon as possible; without publishing any `Message`.
|
||||
pub fn request_redraw() -> Self {
|
||||
Self {
|
||||
redraw_request: window::RedrawRequest::NextFrame,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`Action`] that requests a redraw to happen at
|
||||
/// the given [`Instant`]; without publishing any `Message`.
|
||||
///
|
||||
/// This can be useful to efficiently animate content, like a
|
||||
/// blinking caret on a text input.
|
||||
pub fn request_redraw_at(at: Instant) -> Self {
|
||||
Self {
|
||||
redraw_request: window::RedrawRequest::At(at),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the [`Action`] as "capturing". See [`Self::capture`].
|
||||
pub fn and_capture(mut self) -> Self {
|
||||
self.event_status = event::Status::Captured;
|
||||
self
|
||||
}
|
||||
|
||||
/// Converts the [`Action`] into its internal parts.
|
||||
///
|
||||
/// This method is meant to be used by runtimes, libraries, or internal
|
||||
/// widget implementations.
|
||||
pub fn into_inner(
|
||||
self,
|
||||
) -> (Option<Message>, window::RedrawRequest, event::Status) {
|
||||
(
|
||||
self.message_to_publish,
|
||||
self.redraw_request,
|
||||
self.event_status,
|
||||
)
|
||||
}
|
||||
}
|
||||
-752
@@ -1,752 +0,0 @@
|
||||
//! Buttons allow your users to perform actions by pressing them.
|
||||
//!
|
||||
//! # Example
|
||||
//! ```no_run
|
||||
//! # mod iced { pub mod widget { pub use iced_widget::*; } }
|
||||
//! # pub type State = ();
|
||||
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
//! use iced::widget::button;
|
||||
//!
|
||||
//! #[derive(Clone)]
|
||||
//! enum Message {
|
||||
//! ButtonPressed,
|
||||
//! }
|
||||
//!
|
||||
//! fn view(state: &State) -> Element<'_, Message> {
|
||||
//! button("Press me!").on_press(Message::ButtonPressed).into()
|
||||
//! }
|
||||
//! ```
|
||||
use crate::core::border::{self, Border};
|
||||
use crate::core::layout;
|
||||
use crate::core::mouse;
|
||||
use crate::core::overlay;
|
||||
use crate::core::renderer;
|
||||
use crate::core::theme::palette;
|
||||
use crate::core::touch;
|
||||
use crate::core::widget::Operation;
|
||||
use crate::core::widget::tree::{self, Tree};
|
||||
use crate::core::window;
|
||||
use crate::core::{
|
||||
Background, Clipboard, Color, Element, Event, Layout, Length, Padding,
|
||||
Rectangle, Shadow, Shell, Size, Theme, Vector, Widget,
|
||||
};
|
||||
|
||||
/// A generic widget that produces a message when pressed.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
|
||||
/// # pub type State = ();
|
||||
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
/// use iced::widget::button;
|
||||
///
|
||||
/// #[derive(Clone)]
|
||||
/// enum Message {
|
||||
/// ButtonPressed,
|
||||
/// }
|
||||
///
|
||||
/// fn view(state: &State) -> Element<'_, Message> {
|
||||
/// button("Press me!").on_press(Message::ButtonPressed).into()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// If a [`Button::on_press`] handler is not set, the resulting [`Button`] will
|
||||
/// be disabled:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
|
||||
/// # pub type State = ();
|
||||
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
/// use iced::widget::button;
|
||||
///
|
||||
/// #[derive(Clone)]
|
||||
/// enum Message {
|
||||
/// ButtonPressed,
|
||||
/// }
|
||||
///
|
||||
/// fn view(state: &State) -> Element<'_, Message> {
|
||||
/// button("I am disabled!").into()
|
||||
/// }
|
||||
/// ```
|
||||
pub struct Button<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
Theme: Catalog,
|
||||
{
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
on_press: Option<OnPress<'a, Message>>,
|
||||
width: Length,
|
||||
height: Length,
|
||||
padding: Padding,
|
||||
clip: bool,
|
||||
class: Theme::Class<'a>,
|
||||
status: Option<Status>,
|
||||
}
|
||||
|
||||
enum OnPress<'a, Message> {
|
||||
Direct(Message),
|
||||
Closure(Box<dyn Fn() -> Message + 'a>),
|
||||
}
|
||||
|
||||
impl<Message: Clone> OnPress<'_, Message> {
|
||||
fn get(&self) -> Message {
|
||||
match self {
|
||||
OnPress::Direct(message) => message.clone(),
|
||||
OnPress::Closure(f) => f(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Button<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
Theme: Catalog,
|
||||
{
|
||||
/// Creates a new [`Button`] with the given content.
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
let content = content.into();
|
||||
let size = content.as_widget().size_hint();
|
||||
|
||||
Button {
|
||||
content,
|
||||
on_press: None,
|
||||
width: size.width.fluid(),
|
||||
height: size.height.fluid(),
|
||||
padding: DEFAULT_PADDING,
|
||||
clip: false,
|
||||
class: Theme::default(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Button`].
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Button`].
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Padding`] of the [`Button`].
|
||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||
self.padding = padding.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the message that will be produced when the [`Button`] is pressed.
|
||||
///
|
||||
/// Unless `on_press` is called, the [`Button`] will be disabled.
|
||||
pub fn on_press(mut self, on_press: Message) -> Self {
|
||||
self.on_press = Some(OnPress::Direct(on_press));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the message that will be produced when the [`Button`] is pressed.
|
||||
///
|
||||
/// This is analogous to [`Button::on_press`], but using a closure to produce
|
||||
/// the message.
|
||||
///
|
||||
/// This closure will only be called when the [`Button`] is actually pressed and,
|
||||
/// therefore, this method is useful to reduce overhead if creating the resulting
|
||||
/// message is slow.
|
||||
pub fn on_press_with(
|
||||
mut self,
|
||||
on_press: impl Fn() -> Message + 'a,
|
||||
) -> Self {
|
||||
self.on_press = Some(OnPress::Closure(Box::new(on_press)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the message that will be produced when the [`Button`] is pressed,
|
||||
/// if `Some`.
|
||||
///
|
||||
/// If `None`, the [`Button`] will be disabled.
|
||||
pub fn on_press_maybe(mut self, on_press: Option<Message>) -> Self {
|
||||
self.on_press = on_press.map(OnPress::Direct);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the contents of the [`Button`] should be clipped on
|
||||
/// overflow.
|
||||
pub fn clip(mut self, clip: bool) -> Self {
|
||||
self.clip = clip;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style of the [`Button`].
|
||||
#[must_use]
|
||||
pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style class of the [`Button`].
|
||||
#[cfg(feature = "advanced")]
|
||||
#[must_use]
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.class = class.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct State {
|
||||
is_pressed: bool,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Button<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a + Clone,
|
||||
Renderer: 'a + crate::core::Renderer,
|
||||
Theme: Catalog,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.content)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::padded(
|
||||
limits,
|
||||
self.width,
|
||||
self.height,
|
||||
self.padding,
|
||||
|limits| {
|
||||
self.content.as_widget_mut().layout(
|
||||
&mut tree.children[0],
|
||||
renderer,
|
||||
limits,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.container(None, layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout.children().next().unwrap(),
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout.children().next().unwrap(),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
| Event::Touch(touch::Event::FingerPressed { .. }) => {
|
||||
if self.on_press.is_some() {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if cursor.is_over(bounds) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
state.is_pressed = true;
|
||||
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
| Event::Touch(touch::Event::FingerLifted { .. }) => {
|
||||
if let Some(on_press) = &self.on_press {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
if state.is_pressed {
|
||||
state.is_pressed = false;
|
||||
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if cursor.is_over(bounds) {
|
||||
shell.publish(on_press.get());
|
||||
}
|
||||
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLost { .. }) => {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
state.is_pressed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let current_status = if self.on_press.is_none() {
|
||||
Status::Disabled
|
||||
} else if cursor.is_over(layout.bounds()) {
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
|
||||
if state.is_pressed {
|
||||
Status::Pressed
|
||||
} else {
|
||||
Status::Hovered
|
||||
}
|
||||
} else {
|
||||
Status::Active
|
||||
};
|
||||
|
||||
if let Event::Window(window::Event::RedrawRequested(_now)) = event {
|
||||
self.status = Some(current_status);
|
||||
} else if self.status.is_some_and(|status| status != current_status) {
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
let content_layout = layout.children().next().unwrap();
|
||||
let style =
|
||||
theme.style(&self.class, self.status.unwrap_or(Status::Disabled));
|
||||
|
||||
if style.background.is_some()
|
||||
|| style.border.width > 0.0
|
||||
|| style.shadow.color.a > 0.0
|
||||
{
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: style.border,
|
||||
shadow: style.shadow,
|
||||
snap: style.snap,
|
||||
},
|
||||
style
|
||||
.background
|
||||
.unwrap_or(Background::Color(Color::TRANSPARENT)),
|
||||
);
|
||||
}
|
||||
|
||||
let viewport = if self.clip {
|
||||
bounds.intersection(viewport).unwrap_or(*viewport)
|
||||
} else {
|
||||
*viewport
|
||||
};
|
||||
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
&renderer::Style {
|
||||
text_color: style.text_color,
|
||||
},
|
||||
content_layout,
|
||||
cursor,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let is_mouse_over = cursor.is_over(layout.bounds());
|
||||
|
||||
if is_mouse_over && self.on_press.is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout.children().next().unwrap(),
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Button<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: crate::core::Renderer + 'a,
|
||||
{
|
||||
fn from(button: Button<'a, Message, Theme, Renderer>) -> Self {
|
||||
Self::new(button)
|
||||
}
|
||||
}
|
||||
|
||||
/// The default [`Padding`] of a [`Button`].
|
||||
pub const DEFAULT_PADDING: Padding = Padding {
|
||||
top: 5.0,
|
||||
bottom: 5.0,
|
||||
right: 10.0,
|
||||
left: 10.0,
|
||||
};
|
||||
|
||||
/// The possible status of a [`Button`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// The [`Button`] can be pressed.
|
||||
Active,
|
||||
/// The [`Button`] can be pressed and it is being hovered.
|
||||
Hovered,
|
||||
/// The [`Button`] is being pressed.
|
||||
Pressed,
|
||||
/// The [`Button`] cannot be pressed.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// The style of a button.
|
||||
///
|
||||
/// If not specified with [`Button::style`]
|
||||
/// the theme will provide the style.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Style {
|
||||
/// The [`Background`] of the button.
|
||||
pub background: Option<Background>,
|
||||
/// The text [`Color`] of the button.
|
||||
pub text_color: Color,
|
||||
/// The [`Border`] of the button.
|
||||
pub border: Border,
|
||||
/// The [`Shadow`] of the button.
|
||||
pub shadow: Shadow,
|
||||
/// Whether the button should be snapped to the pixel grid.
|
||||
pub snap: bool,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Updates the [`Style`] with the given [`Background`].
|
||||
pub fn with_background(self, background: impl Into<Background>) -> Self {
|
||||
Self {
|
||||
background: Some(background.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background: None,
|
||||
text_color: Color::BLACK,
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
snap: cfg!(feature = "crisp"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme catalog of a [`Button`].
|
||||
///
|
||||
/// All themes that can be used with [`Button`]
|
||||
/// must implement this trait.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # use iced_widget::core::{Color, Background};
|
||||
/// # use iced_widget::button::{Catalog, Status, Style};
|
||||
/// # struct MyTheme;
|
||||
/// #[derive(Debug, Default)]
|
||||
/// pub enum ButtonClass {
|
||||
/// #[default]
|
||||
/// Primary,
|
||||
/// Secondary,
|
||||
/// Danger
|
||||
/// }
|
||||
///
|
||||
/// impl Catalog for MyTheme {
|
||||
/// type Class<'a> = ButtonClass;
|
||||
///
|
||||
/// fn default<'a>() -> Self::Class<'a> {
|
||||
/// ButtonClass::default()
|
||||
/// }
|
||||
///
|
||||
///
|
||||
/// fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
|
||||
/// let mut style = Style::default();
|
||||
///
|
||||
/// match class {
|
||||
/// ButtonClass::Primary => {
|
||||
/// style.background = Some(Background::Color(Color::from_rgb(0.529, 0.808, 0.921)));
|
||||
/// },
|
||||
/// ButtonClass::Secondary => {
|
||||
/// style.background = Some(Background::Color(Color::WHITE));
|
||||
/// },
|
||||
/// ButtonClass::Danger => {
|
||||
/// style.background = Some(Background::Color(Color::from_rgb(0.941, 0.502, 0.502)));
|
||||
/// },
|
||||
/// }
|
||||
///
|
||||
/// style
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Although, in order to use [`Button::style`]
|
||||
/// with `MyTheme`, [`Catalog::Class`] must implement
|
||||
/// `From<StyleFn<'_, MyTheme>>`.
|
||||
pub trait Catalog {
|
||||
/// The item class of the [`Catalog`].
|
||||
type Class<'a>;
|
||||
|
||||
/// The default class produced by the [`Catalog`].
|
||||
fn default<'a>() -> Self::Class<'a>;
|
||||
|
||||
/// The [`Style`] of a class with the given status.
|
||||
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
|
||||
}
|
||||
|
||||
/// A styling function for a [`Button`].
|
||||
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
|
||||
|
||||
impl Catalog for Theme {
|
||||
type Class<'a> = StyleFn<'a, Self>;
|
||||
|
||||
fn default<'a>() -> Self::Class<'a> {
|
||||
Box::new(primary)
|
||||
}
|
||||
|
||||
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
|
||||
class(self, status)
|
||||
}
|
||||
}
|
||||
|
||||
/// A primary button; denoting a main action.
|
||||
pub fn primary(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.primary.base);
|
||||
|
||||
match status {
|
||||
Status::Active | Status::Pressed => base,
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(palette.primary.strong.color)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A secondary button; denoting a complementary action.
|
||||
pub fn secondary(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.secondary.base);
|
||||
|
||||
match status {
|
||||
Status::Active | Status::Pressed => base,
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(palette.secondary.strong.color)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A success button; denoting a good outcome.
|
||||
pub fn success(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.success.base);
|
||||
|
||||
match status {
|
||||
Status::Active | Status::Pressed => base,
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(palette.success.strong.color)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A warning button; denoting a risky action.
|
||||
pub fn warning(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.warning.base);
|
||||
|
||||
match status {
|
||||
Status::Active | Status::Pressed => base,
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(palette.warning.strong.color)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A danger button; denoting a destructive action.
|
||||
pub fn danger(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.danger.base);
|
||||
|
||||
match status {
|
||||
Status::Active | Status::Pressed => base,
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(palette.danger.strong.color)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text button; useful for links.
|
||||
pub fn text(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
let base = Style {
|
||||
text_color: palette.background.base.text,
|
||||
..Style::default()
|
||||
};
|
||||
|
||||
match status {
|
||||
Status::Active | Status::Pressed => base,
|
||||
Status::Hovered => Style {
|
||||
text_color: palette.background.base.text.scale_alpha(0.8),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A button using background shades.
|
||||
pub fn background(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.background.base);
|
||||
|
||||
match status {
|
||||
Status::Active => base,
|
||||
Status::Pressed => Style {
|
||||
background: Some(Background::Color(
|
||||
palette.background.strong.color,
|
||||
)),
|
||||
..base
|
||||
},
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(palette.background.weak.color)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
/// A subtle button using weak background shades.
|
||||
pub fn subtle(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
let base = styled(palette.background.weakest);
|
||||
|
||||
match status {
|
||||
Status::Active => base,
|
||||
Status::Pressed => Style {
|
||||
background: Some(Background::Color(
|
||||
palette.background.strong.color,
|
||||
)),
|
||||
..base
|
||||
},
|
||||
Status::Hovered => Style {
|
||||
background: Some(Background::Color(
|
||||
palette.background.weaker.color,
|
||||
)),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => disabled(base),
|
||||
}
|
||||
}
|
||||
|
||||
fn styled(pair: palette::Pair) -> Style {
|
||||
Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: border::rounded(2),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled(style: Style) -> Style {
|
||||
Style {
|
||||
background: style
|
||||
.background
|
||||
.map(|background| background.scale_alpha(0.5)),
|
||||
text_color: style.text_color.scale_alpha(0.5),
|
||||
..style
|
||||
}
|
||||
}
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
//! Canvases can be leveraged to draw interactive 2D graphics.
|
||||
//!
|
||||
//! # Example: Drawing a Simple Circle
|
||||
//! ```no_run
|
||||
//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
|
||||
//! # pub type State = ();
|
||||
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
//! #
|
||||
//! use iced::mouse;
|
||||
//! use iced::widget::canvas;
|
||||
//! use iced::{Color, Rectangle, Renderer, Theme};
|
||||
//!
|
||||
//! // First, we define the data we need for drawing
|
||||
//! #[derive(Debug)]
|
||||
//! struct Circle {
|
||||
//! radius: f32,
|
||||
//! }
|
||||
//!
|
||||
//! // Then, we implement the `Program` trait
|
||||
//! impl<Message> canvas::Program<Message> for Circle {
|
||||
//! // No internal state
|
||||
//! type State = ();
|
||||
//!
|
||||
//! fn draw(
|
||||
//! &self,
|
||||
//! _state: &(),
|
||||
//! renderer: &Renderer,
|
||||
//! _theme: &Theme,
|
||||
//! bounds: Rectangle,
|
||||
//! _cursor: mouse::Cursor
|
||||
//! ) -> Vec<canvas::Geometry> {
|
||||
//! // We prepare a new `Frame`
|
||||
//! let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||
//!
|
||||
//! // We create a `Path` representing a simple circle
|
||||
//! let circle = canvas::Path::circle(frame.center(), self.radius);
|
||||
//!
|
||||
//! // And fill it with some color
|
||||
//! frame.fill(&circle, Color::BLACK);
|
||||
//!
|
||||
//! // Then, we produce the geometry
|
||||
//! vec![frame.into_geometry()]
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Finally, we simply use our `Circle` to create the `Canvas`!
|
||||
//! fn view<'a, Message: 'a>(_state: &'a State) -> Element<'a, Message> {
|
||||
//! canvas(Circle { radius: 50.0 }).into()
|
||||
//! }
|
||||
//! ```
|
||||
mod program;
|
||||
|
||||
pub use program::Program;
|
||||
|
||||
pub use crate::Action;
|
||||
pub use crate::core::event::Event;
|
||||
pub use crate::graphics::cache::Group;
|
||||
pub use crate::graphics::geometry::{
|
||||
Fill, Gradient, Image, LineCap, LineDash, LineJoin, Path, Stroke, Style,
|
||||
Text, fill, gradient, path, stroke,
|
||||
};
|
||||
|
||||
use crate::core::event;
|
||||
use crate::core::layout::{self, Layout};
|
||||
use crate::core::mouse;
|
||||
use crate::core::renderer;
|
||||
use crate::core::widget::tree::{self, Tree};
|
||||
use crate::core::window;
|
||||
use crate::core::{
|
||||
Clipboard, Element, Length, Rectangle, Shell, Size, Vector, Widget,
|
||||
};
|
||||
use crate::graphics::geometry;
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// A simple cache that stores generated [`Geometry`] to avoid recomputation.
|
||||
///
|
||||
/// A [`Cache`] will not redraw its geometry unless the dimensions of its layer
|
||||
/// change or it is explicitly cleared.
|
||||
pub type Cache<Renderer = crate::Renderer> = geometry::Cache<Renderer>;
|
||||
|
||||
/// The geometry supported by a renderer.
|
||||
pub type Geometry<Renderer = crate::Renderer> =
|
||||
<Renderer as geometry::Renderer>::Geometry;
|
||||
|
||||
/// The frame supported by a renderer.
|
||||
pub type Frame<Renderer = crate::Renderer> = geometry::Frame<Renderer>;
|
||||
|
||||
/// A widget capable of drawing 2D graphics.
|
||||
///
|
||||
/// # Example: Drawing a Simple Circle
|
||||
/// ```no_run
|
||||
/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
|
||||
/// # pub type State = ();
|
||||
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
/// #
|
||||
/// use iced::mouse;
|
||||
/// use iced::widget::canvas;
|
||||
/// use iced::{Color, Rectangle, Renderer, Theme};
|
||||
///
|
||||
/// // First, we define the data we need for drawing
|
||||
/// #[derive(Debug)]
|
||||
/// struct Circle {
|
||||
/// radius: f32,
|
||||
/// }
|
||||
///
|
||||
/// // Then, we implement the `Program` trait
|
||||
/// impl<Message> canvas::Program<Message> for Circle {
|
||||
/// // No internal state
|
||||
/// type State = ();
|
||||
///
|
||||
/// fn draw(
|
||||
/// &self,
|
||||
/// _state: &(),
|
||||
/// renderer: &Renderer,
|
||||
/// _theme: &Theme,
|
||||
/// bounds: Rectangle,
|
||||
/// _cursor: mouse::Cursor
|
||||
/// ) -> Vec<canvas::Geometry> {
|
||||
/// // We prepare a new `Frame`
|
||||
/// let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||
///
|
||||
/// // We create a `Path` representing a simple circle
|
||||
/// let circle = canvas::Path::circle(frame.center(), self.radius);
|
||||
///
|
||||
/// // And fill it with some color
|
||||
/// frame.fill(&circle, Color::BLACK);
|
||||
///
|
||||
/// // Then, we produce the geometry
|
||||
/// vec![frame.into_geometry()]
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Finally, we simply use our `Circle` to create the `Canvas`!
|
||||
/// fn view<'a, Message: 'a>(_state: &'a State) -> Element<'a, Message> {
|
||||
/// canvas(Circle { radius: 50.0 }).into()
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Canvas<P, Message, Theme = crate::Theme, Renderer = crate::Renderer>
|
||||
where
|
||||
Renderer: geometry::Renderer,
|
||||
P: Program<Message, Theme, Renderer>,
|
||||
{
|
||||
width: Length,
|
||||
height: Length,
|
||||
program: P,
|
||||
message_: PhantomData<Message>,
|
||||
theme_: PhantomData<Theme>,
|
||||
renderer_: PhantomData<Renderer>,
|
||||
last_mouse_interaction: Option<mouse::Interaction>,
|
||||
}
|
||||
|
||||
impl<P, Message, Theme, Renderer> Canvas<P, Message, Theme, Renderer>
|
||||
where
|
||||
P: Program<Message, Theme, Renderer>,
|
||||
Renderer: geometry::Renderer,
|
||||
{
|
||||
const DEFAULT_SIZE: f32 = 100.0;
|
||||
|
||||
/// Creates a new [`Canvas`].
|
||||
pub fn new(program: P) -> Self {
|
||||
Canvas {
|
||||
width: Length::Fixed(Self::DEFAULT_SIZE),
|
||||
height: Length::Fixed(Self::DEFAULT_SIZE),
|
||||
program,
|
||||
message_: PhantomData,
|
||||
theme_: PhantomData,
|
||||
renderer_: PhantomData,
|
||||
last_mouse_interaction: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Canvas`].
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Canvas`].
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<P, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Canvas<P, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: geometry::Renderer,
|
||||
P: Program<Message, Theme, Renderer>,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
struct Tag<T>(T);
|
||||
tree::Tag::of::<Tag<P::State>>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(P::State::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
_tree: &mut Tree,
|
||||
_renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::atomic(limits, self.width, self.height)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
let state = tree.state.downcast_mut::<P::State>();
|
||||
let is_redraw_request = matches!(
|
||||
event,
|
||||
Event::Window(window::Event::RedrawRequested(_now)),
|
||||
);
|
||||
|
||||
if let Some(action) = self.program.update(state, event, bounds, cursor)
|
||||
{
|
||||
let (message, redraw_request, event_status) = action.into_inner();
|
||||
|
||||
shell.request_redraw_at(redraw_request);
|
||||
|
||||
if let Some(message) = message {
|
||||
shell.publish(message);
|
||||
}
|
||||
|
||||
if event_status == event::Status::Captured {
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
|
||||
if shell.redraw_request() != window::RedrawRequest::NextFrame {
|
||||
let mouse_interaction = self
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer);
|
||||
|
||||
if is_redraw_request {
|
||||
self.last_mouse_interaction = Some(mouse_interaction);
|
||||
} else if self.last_mouse_interaction.is_some_and(
|
||||
|last_mouse_interaction| {
|
||||
last_mouse_interaction != mouse_interaction
|
||||
},
|
||||
) {
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let bounds = layout.bounds();
|
||||
let state = tree.state.downcast_ref::<P::State>();
|
||||
|
||||
self.program.mouse_interaction(state, bounds, cursor)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if bounds.width < 1.0 || bounds.height < 1.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = tree.state.downcast_ref::<P::State>();
|
||||
|
||||
renderer.with_translation(
|
||||
Vector::new(bounds.x, bounds.y),
|
||||
|renderer| {
|
||||
let layers =
|
||||
self.program.draw(state, renderer, theme, bounds, cursor);
|
||||
|
||||
for layer in layers {
|
||||
renderer.draw_geometry(layer);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, P, Message, Theme, Renderer> From<Canvas<P, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: 'a + geometry::Renderer,
|
||||
P: 'a + Program<Message, Theme, Renderer>,
|
||||
{
|
||||
fn from(
|
||||
canvas: Canvas<P, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(canvas)
|
||||
}
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
use crate::Action;
|
||||
use crate::canvas::mouse;
|
||||
use crate::canvas::{Event, Geometry};
|
||||
use crate::core::Rectangle;
|
||||
use crate::graphics::geometry;
|
||||
|
||||
/// The state and logic of a [`Canvas`].
|
||||
///
|
||||
/// A [`Program`] can mutate internal state and produce messages for an
|
||||
/// application.
|
||||
///
|
||||
/// [`Canvas`]: crate::Canvas
|
||||
pub trait Program<Message, Theme = crate::Theme, Renderer = crate::Renderer>
|
||||
where
|
||||
Renderer: geometry::Renderer,
|
||||
{
|
||||
/// The internal state mutated by the [`Program`].
|
||||
type State: Default + 'static;
|
||||
|
||||
/// Updates the [`State`](Self::State) of the [`Program`].
|
||||
///
|
||||
/// When a [`Program`] is used in a [`Canvas`], the runtime will call this
|
||||
/// method for each [`Event`].
|
||||
///
|
||||
/// This method can optionally return an [`Action`] to either notify an
|
||||
/// application of any meaningful interactions, capture the event, or
|
||||
/// request a redraw.
|
||||
///
|
||||
/// By default, this method does and returns nothing.
|
||||
///
|
||||
/// [`Canvas`]: crate::Canvas
|
||||
fn update(
|
||||
&self,
|
||||
_state: &mut Self::State,
|
||||
_event: &Event,
|
||||
_bounds: Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Draws the state of the [`Program`], producing a bunch of [`Geometry`].
|
||||
///
|
||||
/// [`Geometry`] can be easily generated with a [`Frame`] or stored in a
|
||||
/// [`Cache`].
|
||||
///
|
||||
/// [`Geometry`]: crate::canvas::Geometry
|
||||
/// [`Frame`]: crate::canvas::Frame
|
||||
/// [`Cache`]: crate::canvas::Cache
|
||||
fn draw(
|
||||
&self,
|
||||
state: &Self::State,
|
||||
renderer: &Renderer,
|
||||
theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Geometry<Renderer>>;
|
||||
|
||||
/// Returns the current mouse interaction of the [`Program`].
|
||||
///
|
||||
/// The interaction returned will be in effect even if the cursor position
|
||||
/// is out of bounds of the program's [`Canvas`].
|
||||
///
|
||||
/// [`Canvas`]: crate::Canvas
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_state: &Self::State,
|
||||
_bounds: Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer, T> Program<Message, Theme, Renderer> for &T
|
||||
where
|
||||
Renderer: geometry::Renderer,
|
||||
T: Program<Message, Theme, Renderer>,
|
||||
{
|
||||
type State = T::State;
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut Self::State,
|
||||
event: &Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
T::update(self, state, event, bounds, cursor)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
state: &Self::State,
|
||||
renderer: &Renderer,
|
||||
theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Geometry<Renderer>> {
|
||||
T::draw(self, state, renderer, theme, bounds, cursor)
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
state: &Self::State,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
T::mouse_interaction(self, state, bounds, cursor)
|
||||
}
|
||||
}
|
||||
-707
@@ -1,707 +0,0 @@
|
||||
//! Checkboxes can be used to let users make binary choices.
|
||||
//!
|
||||
//! # Example
|
||||
//! ```no_run
|
||||
//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
|
||||
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
//! #
|
||||
//! use iced::widget::checkbox;
|
||||
//!
|
||||
//! struct State {
|
||||
//! is_checked: bool,
|
||||
//! }
|
||||
//!
|
||||
//! enum Message {
|
||||
//! CheckboxToggled(bool),
|
||||
//! }
|
||||
//!
|
||||
//! fn view(state: &State) -> Element<'_, Message> {
|
||||
//! checkbox(state.is_checked)
|
||||
//! .label("Toggle me!")
|
||||
//! .on_toggle(Message::CheckboxToggled)
|
||||
//! .into()
|
||||
//! }
|
||||
//!
|
||||
//! fn update(state: &mut State, message: Message) {
|
||||
//! match message {
|
||||
//! Message::CheckboxToggled(is_checked) => {
|
||||
//! state.is_checked = is_checked;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//! 
|
||||
use crate::core::alignment;
|
||||
use crate::core::layout;
|
||||
use crate::core::mouse;
|
||||
use crate::core::renderer;
|
||||
use crate::core::text;
|
||||
use crate::core::theme::palette;
|
||||
use crate::core::touch;
|
||||
use crate::core::widget;
|
||||
use crate::core::widget::tree::{self, Tree};
|
||||
use crate::core::window;
|
||||
use crate::core::{
|
||||
Background, Border, Clipboard, Color, Element, Event, Layout, Length,
|
||||
Pixels, Rectangle, Shell, Size, Theme, Widget,
|
||||
};
|
||||
|
||||
/// A box that can be checked.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
|
||||
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
/// #
|
||||
/// use iced::widget::checkbox;
|
||||
///
|
||||
/// struct State {
|
||||
/// is_checked: bool,
|
||||
/// }
|
||||
///
|
||||
/// enum Message {
|
||||
/// CheckboxToggled(bool),
|
||||
/// }
|
||||
///
|
||||
/// fn view(state: &State) -> Element<'_, Message> {
|
||||
/// checkbox(state.is_checked)
|
||||
/// .label("Toggle me!")
|
||||
/// .on_toggle(Message::CheckboxToggled)
|
||||
/// .into()
|
||||
/// }
|
||||
///
|
||||
/// fn update(state: &mut State, message: Message) {
|
||||
/// match message {
|
||||
/// Message::CheckboxToggled(is_checked) => {
|
||||
/// state.is_checked = is_checked;
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
/// 
|
||||
pub struct Checkbox<
|
||||
'a,
|
||||
Message,
|
||||
Theme = crate::Theme,
|
||||
Renderer = crate::Renderer,
|
||||
> where
|
||||
Renderer: text::Renderer,
|
||||
Theme: Catalog,
|
||||
{
|
||||
is_checked: bool,
|
||||
on_toggle: Option<Box<dyn Fn(bool) -> Message + 'a>>,
|
||||
label: Option<text::Fragment<'a>>,
|
||||
width: Length,
|
||||
size: f32,
|
||||
spacing: f32,
|
||||
text_size: Option<Pixels>,
|
||||
text_line_height: text::LineHeight,
|
||||
text_shaping: text::Shaping,
|
||||
text_wrapping: text::Wrapping,
|
||||
font: Option<Renderer::Font>,
|
||||
icon: Icon<Renderer::Font>,
|
||||
class: Theme::Class<'a>,
|
||||
last_status: Option<Status>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Checkbox<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: text::Renderer,
|
||||
Theme: Catalog,
|
||||
{
|
||||
/// The default size of a [`Checkbox`].
|
||||
const DEFAULT_SIZE: f32 = 16.0;
|
||||
|
||||
/// Creates a new [`Checkbox`].
|
||||
///
|
||||
/// It expects:
|
||||
/// * a boolean describing whether the [`Checkbox`] is checked or not
|
||||
pub fn new(is_checked: bool) -> Self {
|
||||
Checkbox {
|
||||
is_checked,
|
||||
on_toggle: None,
|
||||
label: None,
|
||||
width: Length::Shrink,
|
||||
size: Self::DEFAULT_SIZE,
|
||||
spacing: Self::DEFAULT_SIZE / 2.0,
|
||||
text_size: None,
|
||||
text_line_height: text::LineHeight::default(),
|
||||
text_shaping: text::Shaping::default(),
|
||||
text_wrapping: text::Wrapping::default(),
|
||||
font: None,
|
||||
icon: Icon {
|
||||
font: Renderer::ICON_FONT,
|
||||
code_point: Renderer::CHECKMARK_ICON,
|
||||
size: None,
|
||||
line_height: text::LineHeight::default(),
|
||||
shaping: text::Shaping::Basic,
|
||||
},
|
||||
class: Theme::default(),
|
||||
last_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the label of the [`Checkbox`].
|
||||
pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
|
||||
self.label = Some(label.into_fragment());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the function that will be called when the [`Checkbox`] is toggled.
|
||||
/// It will receive the new state of the [`Checkbox`] and must produce a
|
||||
/// `Message`.
|
||||
///
|
||||
/// Unless `on_toggle` is called, the [`Checkbox`] will be disabled.
|
||||
pub fn on_toggle<F>(mut self, f: F) -> Self
|
||||
where
|
||||
F: 'a + Fn(bool) -> Message,
|
||||
{
|
||||
self.on_toggle = Some(Box::new(f));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the function that will be called when the [`Checkbox`] is toggled,
|
||||
/// if `Some`.
|
||||
///
|
||||
/// If `None`, the checkbox will be disabled.
|
||||
pub fn on_toggle_maybe<F>(mut self, f: Option<F>) -> Self
|
||||
where
|
||||
F: Fn(bool) -> Message + 'a,
|
||||
{
|
||||
self.on_toggle = f.map(|f| Box::new(f) as _);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the size of the [`Checkbox`].
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.size = size.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Checkbox`].
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the spacing between the [`Checkbox`] and the text.
|
||||
pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
|
||||
self.spacing = spacing.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the text size of the [`Checkbox`].
|
||||
pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
|
||||
self.text_size = Some(text_size.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the text [`text::LineHeight`] of the [`Checkbox`].
|
||||
pub fn text_line_height(
|
||||
mut self,
|
||||
line_height: impl Into<text::LineHeight>,
|
||||
) -> Self {
|
||||
self.text_line_height = line_height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`text::Shaping`] strategy of the [`Checkbox`].
|
||||
pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
|
||||
self.text_shaping = shaping;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`text::Wrapping`] strategy of the [`Checkbox`].
|
||||
pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self {
|
||||
self.text_wrapping = wrapping;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Renderer::Font`] of the text of the [`Checkbox`].
|
||||
///
|
||||
/// [`Renderer::Font`]: crate::core::text::Renderer
|
||||
pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
|
||||
self.font = Some(font.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Icon`] of the [`Checkbox`].
|
||||
pub fn icon(mut self, icon: Icon<Renderer::Font>) -> Self {
|
||||
self.icon = icon;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style of the [`Checkbox`].
|
||||
#[must_use]
|
||||
pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style class of the [`Checkbox`].
|
||||
#[cfg(feature = "advanced")]
|
||||
#[must_use]
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.class = class.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Checkbox<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: text::Renderer,
|
||||
Theme: Catalog,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<widget::text::State<Renderer::Paragraph>>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(widget::text::State::<Renderer::Paragraph>::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: Length::Shrink,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::next_to_each_other(
|
||||
&limits.width(self.width),
|
||||
if self.label.is_some() {
|
||||
self.spacing
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
|_| layout::Node::new(Size::new(self.size, self.size)),
|
||||
|limits| {
|
||||
if let Some(label) = self.label.as_deref() {
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_mut::<widget::text::State<Renderer::Paragraph>>();
|
||||
|
||||
widget::text::layout(
|
||||
state,
|
||||
renderer,
|
||||
limits,
|
||||
label,
|
||||
widget::text::Format {
|
||||
width: self.width,
|
||||
height: Length::Shrink,
|
||||
line_height: self.text_line_height,
|
||||
size: self.text_size,
|
||||
font: self.font,
|
||||
align_x: text::Alignment::Default,
|
||||
align_y: alignment::Vertical::Top,
|
||||
shaping: self.text_shaping,
|
||||
wrapping: self.text_wrapping,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
layout::Node::new(Size::ZERO)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
_tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
| Event::Touch(touch::Event::FingerPressed { .. }) => {
|
||||
let mouse_over = cursor.is_over(layout.bounds());
|
||||
|
||||
if mouse_over && let Some(on_toggle) = &self.on_toggle {
|
||||
shell.publish((on_toggle)(!self.is_checked));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let current_status = {
|
||||
let is_mouse_over = cursor.is_over(layout.bounds());
|
||||
let is_disabled = self.on_toggle.is_none();
|
||||
let is_checked = self.is_checked;
|
||||
|
||||
if is_disabled {
|
||||
Status::Disabled { is_checked }
|
||||
} else if is_mouse_over {
|
||||
Status::Hovered { is_checked }
|
||||
} else {
|
||||
Status::Active { is_checked }
|
||||
}
|
||||
};
|
||||
|
||||
if let Event::Window(window::Event::RedrawRequested(_now)) = event {
|
||||
self.last_status = Some(current_status);
|
||||
} else if self
|
||||
.last_status
|
||||
.is_some_and(|status| status != current_status)
|
||||
{
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
if cursor.is_over(layout.bounds()) && self.on_toggle.is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
defaults: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let mut children = layout.children();
|
||||
|
||||
let style = theme.style(
|
||||
&self.class,
|
||||
self.last_status.unwrap_or(Status::Disabled {
|
||||
is_checked: self.is_checked,
|
||||
}),
|
||||
);
|
||||
|
||||
{
|
||||
let layout = children.next().unwrap();
|
||||
let bounds = layout.bounds();
|
||||
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: style.border,
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
style.background,
|
||||
);
|
||||
|
||||
let Icon {
|
||||
font,
|
||||
code_point,
|
||||
size,
|
||||
line_height,
|
||||
shaping,
|
||||
} = &self.icon;
|
||||
let size = size.unwrap_or(Pixels(bounds.height * 0.7));
|
||||
|
||||
if self.is_checked {
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content: code_point.to_string(),
|
||||
font: *font,
|
||||
size,
|
||||
line_height: *line_height,
|
||||
bounds: bounds.size(),
|
||||
align_x: text::Alignment::Center,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: *shaping,
|
||||
wrapping: text::Wrapping::default(),
|
||||
},
|
||||
bounds.center(),
|
||||
style.icon_color,
|
||||
*viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.label.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let label_layout = children.next().unwrap();
|
||||
let state: &widget::text::State<Renderer::Paragraph> =
|
||||
tree.state.downcast_ref();
|
||||
|
||||
crate::text::draw(
|
||||
renderer,
|
||||
defaults,
|
||||
label_layout.bounds(),
|
||||
state.raw(),
|
||||
crate::text::Style {
|
||||
color: style.text_color,
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
_tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
_renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
if let Some(label) = self.label.as_deref() {
|
||||
operation.text(None, layout.bounds(), label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Checkbox<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a + Catalog,
|
||||
Renderer: 'a + text::Renderer,
|
||||
{
|
||||
fn from(
|
||||
checkbox: Checkbox<'a, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(checkbox)
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon in a [`Checkbox`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Icon<Font> {
|
||||
/// Font that will be used to display the `code_point`,
|
||||
pub font: Font,
|
||||
/// The unicode code point that will be used as the icon.
|
||||
pub code_point: char,
|
||||
/// Font size of the content.
|
||||
pub size: Option<Pixels>,
|
||||
/// The line height of the icon.
|
||||
pub line_height: text::LineHeight,
|
||||
/// The shaping strategy of the icon.
|
||||
pub shaping: text::Shaping,
|
||||
}
|
||||
|
||||
/// The possible status of a [`Checkbox`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// The [`Checkbox`] can be interacted with.
|
||||
Active {
|
||||
/// Indicates if the [`Checkbox`] is currently checked.
|
||||
is_checked: bool,
|
||||
},
|
||||
/// The [`Checkbox`] can be interacted with and it is being hovered.
|
||||
Hovered {
|
||||
/// Indicates if the [`Checkbox`] is currently checked.
|
||||
is_checked: bool,
|
||||
},
|
||||
/// The [`Checkbox`] cannot be interacted with.
|
||||
Disabled {
|
||||
/// Indicates if the [`Checkbox`] is currently checked.
|
||||
is_checked: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The style of a checkbox.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Style {
|
||||
/// The [`Background`] of the checkbox.
|
||||
pub background: Background,
|
||||
/// The icon [`Color`] of the checkbox.
|
||||
pub icon_color: Color,
|
||||
/// The [`Border`] of the checkbox.
|
||||
pub border: Border,
|
||||
/// The text [`Color`] of the checkbox.
|
||||
pub text_color: Option<Color>,
|
||||
}
|
||||
|
||||
/// The theme catalog of a [`Checkbox`].
|
||||
pub trait Catalog: Sized {
|
||||
/// The item class of the [`Catalog`].
|
||||
type Class<'a>;
|
||||
|
||||
/// The default class produced by the [`Catalog`].
|
||||
fn default<'a>() -> Self::Class<'a>;
|
||||
|
||||
/// The [`Style`] of a class with the given status.
|
||||
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
|
||||
}
|
||||
|
||||
/// A styling function for a [`Checkbox`].
|
||||
///
|
||||
/// This is just a boxed closure: `Fn(&Theme, Status) -> Style`.
|
||||
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
|
||||
|
||||
impl Catalog for Theme {
|
||||
type Class<'a> = StyleFn<'a, Self>;
|
||||
|
||||
fn default<'a>() -> Self::Class<'a> {
|
||||
Box::new(primary)
|
||||
}
|
||||
|
||||
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
|
||||
class(self, status)
|
||||
}
|
||||
}
|
||||
|
||||
/// A primary checkbox; denoting a main toggle.
|
||||
pub fn primary(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
match status {
|
||||
Status::Active { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.base,
|
||||
palette.primary.base.text,
|
||||
palette.primary.base,
|
||||
is_checked,
|
||||
),
|
||||
Status::Hovered { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.weak,
|
||||
palette.primary.base.text,
|
||||
palette.primary.strong,
|
||||
is_checked,
|
||||
),
|
||||
Status::Disabled { is_checked } => styled(
|
||||
palette.background.weak.color,
|
||||
palette.background.weaker,
|
||||
palette.primary.base.text,
|
||||
palette.background.strong,
|
||||
is_checked,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A secondary checkbox; denoting a complementary toggle.
|
||||
pub fn secondary(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
match status {
|
||||
Status::Active { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.base,
|
||||
palette.background.base.text,
|
||||
palette.background.strong,
|
||||
is_checked,
|
||||
),
|
||||
Status::Hovered { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.weak,
|
||||
palette.background.base.text,
|
||||
palette.background.strong,
|
||||
is_checked,
|
||||
),
|
||||
Status::Disabled { is_checked } => styled(
|
||||
palette.background.weak.color,
|
||||
palette.background.weak,
|
||||
palette.background.base.text,
|
||||
palette.background.weak,
|
||||
is_checked,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A success checkbox; denoting a positive toggle.
|
||||
pub fn success(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
match status {
|
||||
Status::Active { is_checked } => styled(
|
||||
palette.background.weak.color,
|
||||
palette.background.base,
|
||||
palette.success.base.text,
|
||||
palette.success.base,
|
||||
is_checked,
|
||||
),
|
||||
Status::Hovered { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.weak,
|
||||
palette.success.base.text,
|
||||
palette.success.strong,
|
||||
is_checked,
|
||||
),
|
||||
Status::Disabled { is_checked } => styled(
|
||||
palette.background.weak.color,
|
||||
palette.background.weak,
|
||||
palette.success.base.text,
|
||||
palette.success.weak,
|
||||
is_checked,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A danger checkbox; denoting a negative toggle.
|
||||
pub fn danger(theme: &Theme, status: Status) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
match status {
|
||||
Status::Active { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.base,
|
||||
palette.danger.base.text,
|
||||
palette.danger.base,
|
||||
is_checked,
|
||||
),
|
||||
Status::Hovered { is_checked } => styled(
|
||||
palette.background.strong.color,
|
||||
palette.background.weak,
|
||||
palette.danger.base.text,
|
||||
palette.danger.strong,
|
||||
is_checked,
|
||||
),
|
||||
Status::Disabled { is_checked } => styled(
|
||||
palette.background.weak.color,
|
||||
palette.background.weak,
|
||||
palette.danger.base.text,
|
||||
palette.danger.weak,
|
||||
is_checked,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn styled(
|
||||
border_color: Color,
|
||||
base: palette::Pair,
|
||||
icon_color: Color,
|
||||
accent: palette::Pair,
|
||||
is_checked: bool,
|
||||
) -> Style {
|
||||
let (background, border) = if is_checked {
|
||||
(accent, accent.color)
|
||||
} else {
|
||||
(base, border_color)
|
||||
};
|
||||
|
||||
Style {
|
||||
background: Background::Color(background.color),
|
||||
icon_color,
|
||||
border: Border {
|
||||
radius: 2.0.into(),
|
||||
width: 1.0,
|
||||
color: border,
|
||||
},
|
||||
text_color: None,
|
||||
}
|
||||
}
|
||||
-617
@@ -1,617 +0,0 @@
|
||||
//! Distribute content vertically.
|
||||
use crate::core::alignment::{self, Alignment};
|
||||
use crate::core::layout;
|
||||
use crate::core::mouse;
|
||||
use crate::core::overlay;
|
||||
use crate::core::renderer;
|
||||
use crate::core::widget::{Operation, Tree};
|
||||
use crate::core::{
|
||||
Clipboard, Element, Event, Layout, Length, Padding, Pixels, Rectangle,
|
||||
Shell, Size, Vector, Widget,
|
||||
};
|
||||
|
||||
/// A container that distributes its contents vertically.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
|
||||
/// # pub type State = ();
|
||||
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
/// use iced::widget::{button, column};
|
||||
///
|
||||
/// #[derive(Debug, Clone)]
|
||||
/// enum Message {
|
||||
/// // ...
|
||||
/// }
|
||||
///
|
||||
/// fn view(state: &State) -> Element<'_, Message> {
|
||||
/// column![
|
||||
/// "I am on top!",
|
||||
/// button("I am in the center!"),
|
||||
/// "I am below.",
|
||||
/// ].into()
|
||||
/// }
|
||||
/// ```
|
||||
pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
|
||||
{
|
||||
spacing: f32,
|
||||
padding: Padding,
|
||||
width: Length,
|
||||
height: Length,
|
||||
max_width: f32,
|
||||
align: Alignment,
|
||||
clip: bool,
|
||||
children: Vec<Element<'a, Message, Theme, Renderer>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
/// Creates an empty [`Column`].
|
||||
pub fn new() -> Self {
|
||||
Self::from_vec(Vec::new())
|
||||
}
|
||||
|
||||
/// Creates a [`Column`] with the given capacity.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self::from_vec(Vec::with_capacity(capacity))
|
||||
}
|
||||
|
||||
/// Creates a [`Column`] with the given elements.
|
||||
pub fn with_children(
|
||||
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
let iterator = children.into_iter();
|
||||
|
||||
Self::with_capacity(iterator.size_hint().0).extend(iterator)
|
||||
}
|
||||
|
||||
/// Creates a [`Column`] from an already allocated [`Vec`].
|
||||
///
|
||||
/// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means
|
||||
/// it won't automatically adapt to the sizing strategy of its contents.
|
||||
///
|
||||
/// If any of the children have a [`Length::Fill`] strategy, you will need to
|
||||
/// call [`Column::width`] or [`Column::height`] accordingly.
|
||||
pub fn from_vec(
|
||||
children: Vec<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
spacing: 0.0,
|
||||
padding: Padding::ZERO,
|
||||
width: Length::Shrink,
|
||||
height: Length::Shrink,
|
||||
max_width: f32::INFINITY,
|
||||
align: Alignment::Start,
|
||||
clip: false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the vertical spacing _between_ elements.
|
||||
///
|
||||
/// Custom margins per element do not exist in iced. You should use this
|
||||
/// method instead! While less flexible, it helps you keep spacing between
|
||||
/// elements consistent.
|
||||
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
|
||||
self.spacing = amount.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Padding`] of the [`Column`].
|
||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||
self.padding = padding.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Column`].
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Column`].
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum width of the [`Column`].
|
||||
pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self {
|
||||
self.max_width = max_width.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the horizontal alignment of the contents of the [`Column`] .
|
||||
pub fn align_x(mut self, align: impl Into<alignment::Horizontal>) -> Self {
|
||||
self.align = Alignment::from(align.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the contents of the [`Column`] should be clipped on
|
||||
/// overflow.
|
||||
pub fn clip(mut self, clip: bool) -> Self {
|
||||
self.clip = clip;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an element to the [`Column`].
|
||||
pub fn push(
|
||||
mut self,
|
||||
child: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
let child = child.into();
|
||||
let child_size = child.as_widget().size_hint();
|
||||
|
||||
if !child_size.is_void() {
|
||||
self.width = self.width.enclose(child_size.width);
|
||||
self.height = self.height.enclose(child_size.height);
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Extends the [`Column`] with the given children.
|
||||
pub fn extend(
|
||||
self,
|
||||
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
children.into_iter().fold(self, Self::push)
|
||||
}
|
||||
|
||||
/// Turns the [`Column`] into a [`Wrapping`] column.
|
||||
///
|
||||
/// The original alignment of the [`Column`] is preserved per column wrapped.
|
||||
pub fn wrap(self) -> Wrapping<'a, Message, Theme, Renderer> {
|
||||
Wrapping {
|
||||
column: self,
|
||||
horizontal_spacing: None,
|
||||
align_y: alignment::Vertical::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Renderer> Default for Column<'_, Message, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer: crate::core::Renderer>
|
||||
FromIterator<Element<'a, Message, Theme, Renderer>>
|
||||
for Column<'a, Message, Theme, Renderer>
|
||||
{
|
||||
fn from_iter<
|
||||
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
|
||||
>(
|
||||
iter: T,
|
||||
) -> Self {
|
||||
Self::with_children(iter)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Column<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
self.children.iter().map(Tree::new).collect()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(&self.children);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let limits = limits.max_width(self.max_width);
|
||||
|
||||
layout::flex::resolve(
|
||||
layout::flex::Axis::Vertical,
|
||||
renderer,
|
||||
&limits,
|
||||
self.width,
|
||||
self.height,
|
||||
self.padding,
|
||||
self.spacing,
|
||||
self.align,
|
||||
&mut self.children,
|
||||
&mut tree.children,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.container(None, layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.children
|
||||
.iter_mut()
|
||||
.zip(&mut tree.children)
|
||||
.zip(layout.children())
|
||||
.for_each(|((child, state), layout)| {
|
||||
child
|
||||
.as_widget_mut()
|
||||
.operate(state, layout, renderer, operation);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
for ((child, tree), layout) in self
|
||||
.children
|
||||
.iter_mut()
|
||||
.zip(&mut tree.children)
|
||||
.zip(layout.children())
|
||||
{
|
||||
child.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.children
|
||||
.iter()
|
||||
.zip(&tree.children)
|
||||
.zip(layout.children())
|
||||
.map(|((child, tree), layout)| {
|
||||
child
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
})
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if let Some(clipped_viewport) = layout.bounds().intersection(viewport) {
|
||||
let viewport = if self.clip {
|
||||
&clipped_viewport
|
||||
} else {
|
||||
viewport
|
||||
};
|
||||
|
||||
for ((child, tree), layout) in self
|
||||
.children
|
||||
.iter()
|
||||
.zip(&tree.children)
|
||||
.zip(layout.children())
|
||||
.filter(|(_, layout)| layout.bounds().intersects(viewport))
|
||||
{
|
||||
child.as_widget().draw(
|
||||
tree, renderer, theme, style, layout, cursor, viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
overlay::from_children(
|
||||
&mut self.children,
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Column<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: crate::core::Renderer + 'a,
|
||||
{
|
||||
fn from(column: Column<'a, Message, Theme, Renderer>) -> Self {
|
||||
Self::new(column)
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Column`] that wraps its contents.
|
||||
///
|
||||
/// Create a [`Column`] first, and then call [`Column::wrap`] to
|
||||
/// obtain a [`Column`] that wraps its contents.
|
||||
///
|
||||
/// The original alignment of the [`Column`] is preserved per column wrapped.
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub struct Wrapping<
|
||||
'a,
|
||||
Message,
|
||||
Theme = crate::Theme,
|
||||
Renderer = crate::Renderer,
|
||||
> {
|
||||
column: Column<'a, Message, Theme, Renderer>,
|
||||
horizontal_spacing: Option<f32>,
|
||||
align_y: alignment::Vertical,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Wrapping<'_, Message, Theme, Renderer> {
|
||||
/// Sets the horizontal spacing _between_ columns.
|
||||
pub fn horizontal_spacing(mut self, amount: impl Into<Pixels>) -> Self {
|
||||
self.horizontal_spacing = Some(amount.into().0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the vertical alignment of the wrapping [`Column`].
|
||||
pub fn align_x(mut self, align_y: impl Into<alignment::Vertical>) -> Self {
|
||||
self.align_y = align_y.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Wrapping<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
self.column.children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
self.column.diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.column.size()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let limits = limits
|
||||
.width(self.column.width)
|
||||
.height(self.column.height)
|
||||
.shrink(self.column.padding);
|
||||
|
||||
let child_limits = limits.loose();
|
||||
let spacing = self.column.spacing;
|
||||
let horizontal_spacing = self.horizontal_spacing.unwrap_or(spacing);
|
||||
let max_height = limits.max().height;
|
||||
|
||||
let mut children: Vec<layout::Node> = Vec::new();
|
||||
let mut intrinsic_size = Size::ZERO;
|
||||
let mut column_start = 0;
|
||||
let mut column_width = 0.0;
|
||||
let mut x = 0.0;
|
||||
let mut y = 0.0;
|
||||
|
||||
let align_factor = match self.column.align {
|
||||
Alignment::Start => 0.0,
|
||||
Alignment::Center => 2.0,
|
||||
Alignment::End => 1.0,
|
||||
};
|
||||
|
||||
let align_x = |column_start: std::ops::Range<usize>,
|
||||
column_width: f32,
|
||||
children: &mut Vec<layout::Node>| {
|
||||
if align_factor != 0.0 {
|
||||
for node in &mut children[column_start] {
|
||||
let width = node.size().width;
|
||||
|
||||
node.translate_mut(Vector::new(
|
||||
(column_width - width) / align_factor,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (i, child) in self.column.children.iter_mut().enumerate() {
|
||||
let node = child.as_widget_mut().layout(
|
||||
&mut tree.children[i],
|
||||
renderer,
|
||||
&child_limits,
|
||||
);
|
||||
|
||||
let child_size = node.size();
|
||||
|
||||
if y != 0.0 && y + child_size.height > max_height {
|
||||
intrinsic_size.height = intrinsic_size.height.max(y - spacing);
|
||||
|
||||
align_x(column_start..i, column_width, &mut children);
|
||||
|
||||
x += column_width + horizontal_spacing;
|
||||
y = 0.0;
|
||||
column_start = i;
|
||||
column_width = 0.0;
|
||||
}
|
||||
|
||||
column_width = column_width.max(child_size.width);
|
||||
|
||||
children.push(node.move_to((
|
||||
x + self.column.padding.left,
|
||||
y + self.column.padding.top,
|
||||
)));
|
||||
|
||||
y += child_size.height + spacing;
|
||||
}
|
||||
|
||||
if y != 0.0 {
|
||||
intrinsic_size.height = intrinsic_size.height.max(y - spacing);
|
||||
}
|
||||
|
||||
intrinsic_size.width = x + column_width;
|
||||
align_x(column_start..children.len(), column_width, &mut children);
|
||||
|
||||
let align_factor = match self.align_y {
|
||||
alignment::Vertical::Top => 0.0,
|
||||
alignment::Vertical::Center => 2.0,
|
||||
alignment::Vertical::Bottom => 1.0,
|
||||
};
|
||||
|
||||
if align_factor != 0.0 {
|
||||
let total_height = intrinsic_size.height;
|
||||
|
||||
let mut column_start = 0;
|
||||
|
||||
for i in 0..children.len() {
|
||||
let bounds = children[i].bounds();
|
||||
let column_height = bounds.y + bounds.height;
|
||||
|
||||
let next_y = children
|
||||
.get(i + 1)
|
||||
.map(|node| node.bounds().y)
|
||||
.unwrap_or_default();
|
||||
|
||||
if next_y == 0.0 {
|
||||
let translation = Vector::new(
|
||||
0.0,
|
||||
(total_height - column_height) / align_factor,
|
||||
);
|
||||
|
||||
for node in &mut children[column_start..=i] {
|
||||
node.translate_mut(translation);
|
||||
}
|
||||
|
||||
column_start = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let size = limits.resolve(
|
||||
self.column.width,
|
||||
self.column.height,
|
||||
intrinsic_size,
|
||||
);
|
||||
|
||||
layout::Node::with_children(size.expand(self.column.padding), children)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
self.column.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.column.update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.column
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.column
|
||||
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.column
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Wrapping<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: crate::core::Renderer + 'a,
|
||||
{
|
||||
fn from(column: Wrapping<'a, Message, Theme, Renderer>) -> Self {
|
||||
Self::new(column)
|
||||
}
|
||||
}
|
||||
-1013
File diff suppressed because it is too large
Load Diff
-660
@@ -1,660 +0,0 @@
|
||||
//! Containers let you align a widget inside their boundaries.
|
||||
//!
|
||||
//! # Example
|
||||
//! ```no_run
|
||||
//! # mod iced { pub mod widget { pub use iced_widget::*; } }
|
||||
//! # pub type State = ();
|
||||
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
//! use iced::widget::container;
|
||||
//!
|
||||
//! enum Message {
|
||||
//! // ...
|
||||
//! }
|
||||
//!
|
||||
//! fn view(state: &State) -> Element<'_, Message> {
|
||||
//! container("This text is centered inside a rounded box!")
|
||||
//! .padding(10)
|
||||
//! .center(800)
|
||||
//! .style(container::rounded_box)
|
||||
//! .into()
|
||||
//! }
|
||||
//! ```
|
||||
use crate::core::alignment::{self, Alignment};
|
||||
use crate::core::border::{self, Border};
|
||||
use crate::core::gradient::{self, Gradient};
|
||||
use crate::core::layout;
|
||||
use crate::core::mouse;
|
||||
use crate::core::overlay;
|
||||
use crate::core::renderer;
|
||||
use crate::core::theme;
|
||||
use crate::core::widget::tree::{self, Tree};
|
||||
use crate::core::widget::{self, Operation};
|
||||
use crate::core::{
|
||||
self, Background, Clipboard, Color, Element, Event, Layout, Length,
|
||||
Padding, Pixels, Rectangle, Shadow, Shell, Size, Theme, Vector, Widget,
|
||||
color,
|
||||
};
|
||||
|
||||
/// A widget that aligns its contents inside of its boundaries.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
|
||||
/// # pub type State = ();
|
||||
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
||||
/// use iced::widget::container;
|
||||
///
|
||||
/// enum Message {
|
||||
/// // ...
|
||||
/// }
|
||||
///
|
||||
/// fn view(state: &State) -> Element<'_, Message> {
|
||||
/// container("This text is centered inside a rounded box!")
|
||||
/// .padding(10)
|
||||
/// .center(800)
|
||||
/// .style(container::rounded_box)
|
||||
/// .into()
|
||||
/// }
|
||||
/// ```
|
||||
pub struct Container<
|
||||
'a,
|
||||
Message,
|
||||
Theme = crate::Theme,
|
||||
Renderer = crate::Renderer,
|
||||
> where
|
||||
Theme: Catalog,
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
id: Option<widget::Id>,
|
||||
padding: Padding,
|
||||
width: Length,
|
||||
height: Length,
|
||||
max_width: f32,
|
||||
max_height: f32,
|
||||
horizontal_alignment: alignment::Horizontal,
|
||||
vertical_alignment: alignment::Vertical,
|
||||
clip: bool,
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
class: Theme::Class<'a>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Container<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
/// Creates a [`Container`] with the given content.
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
let content = content.into();
|
||||
let size = content.as_widget().size_hint();
|
||||
|
||||
Container {
|
||||
id: None,
|
||||
padding: Padding::ZERO,
|
||||
width: size.width.fluid(),
|
||||
height: size.height.fluid(),
|
||||
max_width: f32::INFINITY,
|
||||
max_height: f32::INFINITY,
|
||||
horizontal_alignment: alignment::Horizontal::Left,
|
||||
vertical_alignment: alignment::Vertical::Top,
|
||||
clip: false,
|
||||
class: Theme::default(),
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the [`widget::Id`] of the [`Container`].
|
||||
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
||||
self.id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Padding`] of the [`Container`].
|
||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||
self.padding = padding.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Container`].
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Container`].
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum width of the [`Container`].
|
||||
pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self {
|
||||
self.max_width = max_width.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum height of the [`Container`].
|
||||
pub fn max_height(mut self, max_height: impl Into<Pixels>) -> Self {
|
||||
self.max_height = max_height.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Container`] and centers its contents horizontally.
|
||||
pub fn center_x(self, width: impl Into<Length>) -> Self {
|
||||
self.width(width).align_x(alignment::Horizontal::Center)
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Container`] and centers its contents vertically.
|
||||
pub fn center_y(self, height: impl Into<Length>) -> Self {
|
||||
self.height(height).align_y(alignment::Vertical::Center)
|
||||
}
|
||||
|
||||
/// Sets the width and height of the [`Container`] and centers its contents in
|
||||
/// both the horizontal and vertical axes.
|
||||
///
|
||||
/// This is equivalent to chaining [`center_x`] and [`center_y`].
|
||||
///
|
||||
/// [`center_x`]: Self::center_x
|
||||
/// [`center_y`]: Self::center_y
|
||||
pub fn center(self, length: impl Into<Length>) -> Self {
|
||||
let length = length.into();
|
||||
|
||||
self.center_x(length).center_y(length)
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Container`] and aligns its contents to the left.
|
||||
pub fn align_left(self, width: impl Into<Length>) -> Self {
|
||||
self.width(width).align_x(alignment::Horizontal::Left)
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Container`] and aligns its contents to the right.
|
||||
pub fn align_right(self, width: impl Into<Length>) -> Self {
|
||||
self.width(width).align_x(alignment::Horizontal::Right)
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Container`] and aligns its contents to the top.
|
||||
pub fn align_top(self, height: impl Into<Length>) -> Self {
|
||||
self.height(height).align_y(alignment::Vertical::Top)
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Container`] and aligns its contents to the bottom.
|
||||
pub fn align_bottom(self, height: impl Into<Length>) -> Self {
|
||||
self.height(height).align_y(alignment::Vertical::Bottom)
|
||||
}
|
||||
|
||||
/// Sets the content alignment for the horizontal axis of the [`Container`].
|
||||
pub fn align_x(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Horizontal>,
|
||||
) -> Self {
|
||||
self.horizontal_alignment = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the content alignment for the vertical axis of the [`Container`].
|
||||
pub fn align_y(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Vertical>,
|
||||
) -> Self {
|
||||
self.vertical_alignment = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the contents of the [`Container`] should be clipped on
|
||||
/// overflow.
|
||||
pub fn clip(mut self, clip: bool) -> Self {
|
||||
self.clip = clip;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style of the [`Container`].
|
||||
#[must_use]
|
||||
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style class of the [`Container`].
|
||||
#[must_use]
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.class = class.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Container<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout(
|
||||
limits,
|
||||
self.width,
|
||||
self.height,
|
||||
self.max_width,
|
||||
self.max_height,
|
||||
self.padding,
|
||||
self.horizontal_alignment,
|
||||
self.vertical_alignment,
|
||||
|limits| {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.container(self.id.as_ref(), layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
tree,
|
||||
layout.children().next().unwrap(),
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
tree,
|
||||
event,
|
||||
layout.children().next().unwrap(),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
tree,
|
||||
layout.children().next().unwrap(),
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
let style = theme.style(&self.class);
|
||||
|
||||
if let Some(clipped_viewport) = bounds.intersection(viewport) {
|
||||
draw_background(renderer, &style, bounds);
|
||||
|
||||
self.content.as_widget().draw(
|
||||
tree,
|
||||
renderer,
|
||||
theme,
|
||||
&renderer::Style {
|
||||
text_color: style
|
||||
.text_color
|
||||
.unwrap_or(renderer_style.text_color),
|
||||
},
|
||||
layout.children().next().unwrap(),
|
||||
cursor,
|
||||
if self.clip {
|
||||
&clipped_viewport
|
||||
} else {
|
||||
viewport
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
tree,
|
||||
layout.children().next().unwrap(),
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Container<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: core::Renderer + 'a,
|
||||
{
|
||||
fn from(
|
||||
container: Container<'a, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(container)
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the layout of a [`Container`].
|
||||
pub fn layout(
|
||||
limits: &layout::Limits,
|
||||
width: Length,
|
||||
height: Length,
|
||||
max_width: f32,
|
||||
max_height: f32,
|
||||
padding: Padding,
|
||||
horizontal_alignment: alignment::Horizontal,
|
||||
vertical_alignment: alignment::Vertical,
|
||||
layout_content: impl FnOnce(&layout::Limits) -> layout::Node,
|
||||
) -> layout::Node {
|
||||
layout::positioned(
|
||||
&limits.max_width(max_width).max_height(max_height),
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
|limits| layout_content(&limits.loose()),
|
||||
|content, size| {
|
||||
content.align(
|
||||
Alignment::from(horizontal_alignment),
|
||||
Alignment::from(vertical_alignment),
|
||||
size,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws the background of a [`Container`] given its [`Style`] and its `bounds`.
|
||||
pub fn draw_background<Renderer>(
|
||||
renderer: &mut Renderer,
|
||||
style: &Style,
|
||||
bounds: Rectangle,
|
||||
) where
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
if style.background.is_some()
|
||||
|| style.border.width > 0.0
|
||||
|| style.shadow.color.a > 0.0
|
||||
{
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: style.border,
|
||||
shadow: style.shadow,
|
||||
snap: style.snap,
|
||||
},
|
||||
style
|
||||
.background
|
||||
.unwrap_or(Background::Color(Color::TRANSPARENT)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The appearance of a container.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Style {
|
||||
/// The text [`Color`] of the container.
|
||||
pub text_color: Option<Color>,
|
||||
/// The [`Background`] of the container.
|
||||
pub background: Option<Background>,
|
||||
/// The [`Border`] of the container.
|
||||
pub border: Border,
|
||||
/// The [`Shadow`] of the container.
|
||||
pub shadow: Shadow,
|
||||
/// Whether the container should be snapped to the pixel grid.
|
||||
pub snap: bool,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text_color: None,
|
||||
background: None,
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
snap: cfg!(feature = "crisp"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Updates the text color of the [`Style`].
|
||||
pub fn color(self, color: impl Into<Color>) -> Self {
|
||||
Self {
|
||||
text_color: Some(color.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the border of the [`Style`].
|
||||
pub fn border(self, border: impl Into<Border>) -> Self {
|
||||
Self {
|
||||
border: border.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the background of the [`Style`].
|
||||
pub fn background(self, background: impl Into<Background>) -> Self {
|
||||
Self {
|
||||
background: Some(background.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the shadow of the [`Style`].
|
||||
pub fn shadow(self, shadow: impl Into<Shadow>) -> Self {
|
||||
Self {
|
||||
shadow: shadow.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Color> for Style {
|
||||
fn from(color: Color) -> Self {
|
||||
Self::default().background(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Gradient> for Style {
|
||||
fn from(gradient: Gradient) -> Self {
|
||||
Self::default().background(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gradient::Linear> for Style {
|
||||
fn from(gradient: gradient::Linear) -> Self {
|
||||
Self::default().background(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme catalog of a [`Container`].
|
||||
pub trait Catalog {
|
||||
/// The item class of the [`Catalog`].
|
||||
type Class<'a>;
|
||||
|
||||
/// The default class produced by the [`Catalog`].
|
||||
fn default<'a>() -> Self::Class<'a>;
|
||||
|
||||
/// The [`Style`] of a class with the given status.
|
||||
fn style(&self, class: &Self::Class<'_>) -> Style;
|
||||
}
|
||||
|
||||
/// A styling function for a [`Container`].
|
||||
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
|
||||
|
||||
impl<Theme> From<Style> for StyleFn<'_, Theme> {
|
||||
fn from(style: Style) -> Self {
|
||||
Box::new(move |_theme| style)
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog for Theme {
|
||||
type Class<'a> = StyleFn<'a, Self>;
|
||||
|
||||
fn default<'a>() -> Self::Class<'a> {
|
||||
Box::new(transparent)
|
||||
}
|
||||
|
||||
fn style(&self, class: &Self::Class<'_>) -> Style {
|
||||
class(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A transparent [`Container`].
|
||||
pub fn transparent<Theme>(_theme: &Theme) -> Style {
|
||||
Style::default()
|
||||
}
|
||||
|
||||
/// A [`Container`] with the given [`Background`].
|
||||
pub fn background(background: impl Into<Background>) -> Style {
|
||||
Style::default().background(background)
|
||||
}
|
||||
|
||||
/// A rounded [`Container`] with a background.
|
||||
pub fn rounded_box(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
Style {
|
||||
background: Some(palette.background.weak.color.into()),
|
||||
text_color: Some(palette.background.weak.text),
|
||||
border: border::rounded(2),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A bordered [`Container`] with a background.
|
||||
pub fn bordered_box(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
Style {
|
||||
background: Some(palette.background.weakest.color.into()),
|
||||
text_color: Some(palette.background.weakest.text),
|
||||
border: Border {
|
||||
width: 1.0,
|
||||
radius: 5.0.into(),
|
||||
color: palette.background.weak.color,
|
||||
},
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Container`] with a dark background and white text.
|
||||
pub fn dark(_theme: &Theme) -> Style {
|
||||
style(theme::palette::Pair {
|
||||
color: color!(0x111111),
|
||||
text: Color::WHITE,
|
||||
})
|
||||
}
|
||||
|
||||
/// A [`Container`] with a primary background color.
|
||||
pub fn primary(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
style(palette.primary.base)
|
||||
}
|
||||
|
||||
/// A [`Container`] with a secondary background color.
|
||||
pub fn secondary(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
style(palette.secondary.base)
|
||||
}
|
||||
|
||||
/// A [`Container`] with a success background color.
|
||||
pub fn success(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
style(palette.success.base)
|
||||
}
|
||||
|
||||
/// A [`Container`] with a warning background color.
|
||||
pub fn warning(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
style(palette.warning.base)
|
||||
}
|
||||
|
||||
/// A [`Container`] with a danger background color.
|
||||
pub fn danger(theme: &Theme) -> Style {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
style(palette.danger.base)
|
||||
}
|
||||
|
||||
fn style(pair: theme::palette::Pair) -> Style {
|
||||
Style {
|
||||
background: Some(pair.color.into()),
|
||||
text_color: Some(pair.text),
|
||||
border: border::rounded(2),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
-432
@@ -1,432 +0,0 @@
|
||||
//! Make elements float!
|
||||
use crate::core;
|
||||
use crate::core::border;
|
||||
use crate::core::layout;
|
||||
use crate::core::mouse;
|
||||
use crate::core::overlay;
|
||||
use crate::core::renderer;
|
||||
use crate::core::widget;
|
||||
use crate::core::widget::tree;
|
||||
use crate::core::{
|
||||
Clipboard, Element, Event, Layout, Length, Rectangle, Shadow, Shell, Size,
|
||||
Transformation, Vector, Widget,
|
||||
};
|
||||
|
||||
/// A widget that can make its contents float over other widgets.
|
||||
pub struct Float<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
{
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
scale: f32,
|
||||
translate: Option<Box<dyn Fn(Rectangle, Rectangle) -> Vector + 'a>>,
|
||||
class: Theme::Class<'a>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Float<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
{
|
||||
/// Creates a new [`Float`] widget with the given content.
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scale: 1.0,
|
||||
translate: None,
|
||||
class: Theme::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the scale to be applied to the contents of the [`Float`].
|
||||
pub fn scale(mut self, scale: f32) -> Self {
|
||||
self.scale = scale;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the translation logic to be applied to the contents of the [`Float`].
|
||||
///
|
||||
/// The logic takes the original (non-scaled) bounds of the contents and the
|
||||
/// viewport bounds. These bounds can be useful to ensure the floating elements
|
||||
/// always stay on screen.
|
||||
pub fn translate(
|
||||
mut self,
|
||||
translate: impl Fn(Rectangle, Rectangle) -> Vector + 'a,
|
||||
) -> Self {
|
||||
self.translate = Some(Box::new(translate));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style of the [`Float`].
|
||||
#[must_use]
|
||||
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the style class of the [`Float`].
|
||||
#[cfg(feature = "advanced")]
|
||||
#[must_use]
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.class = class.into();
|
||||
self
|
||||
}
|
||||
|
||||
fn is_floating(&self, bounds: Rectangle, viewport: Rectangle) -> bool {
|
||||
self.scale > 1.0
|
||||
|| self.translate.as_ref().is_some_and(|translate| {
|
||||
translate(bounds, viewport) != Vector::ZERO
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Float<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<tree::Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.is_floating(layout.bounds(), *viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.is_floating(layout.bounds(), *viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let style = theme.style(&self.class);
|
||||
|
||||
if style.shadow.color.a > 0.0 {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds().shrink(1.0),
|
||||
shadow: style.shadow,
|
||||
border: border::rounded(style.shadow_border_radius),
|
||||
snap: false,
|
||||
},
|
||||
style.shadow.color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
if self.is_floating(layout.bounds(), *viewport) {
|
||||
return mouse::Interaction::None;
|
||||
}
|
||||
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
state: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
offset: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
let translation = self
|
||||
.translate
|
||||
.as_ref()
|
||||
.map(|translate| translate(bounds + offset, *viewport))
|
||||
.unwrap_or(Vector::ZERO);
|
||||
|
||||
if self.scale > 1.0 || translation != Vector::ZERO {
|
||||
let translation = translation + offset;
|
||||
|
||||
let transformation = Transformation::translate(
|
||||
bounds.x + bounds.width / 2.0 + translation.x,
|
||||
bounds.y + bounds.height / 2.0 + translation.y,
|
||||
) * Transformation::scale(self.scale)
|
||||
* Transformation::translate(
|
||||
-bounds.x - bounds.width / 2.0,
|
||||
-bounds.y - bounds.height / 2.0,
|
||||
);
|
||||
|
||||
Some(overlay::Element::new(Box::new(Overlay {
|
||||
float: self,
|
||||
state,
|
||||
layout,
|
||||
viewport: *viewport,
|
||||
transformation,
|
||||
})))
|
||||
} else {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(state, layout, renderer, viewport, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Float<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: core::Renderer + 'a,
|
||||
{
|
||||
fn from(float: Float<'a, Message, Theme, Renderer>) -> Self {
|
||||
Element::new(float)
|
||||
}
|
||||
}
|
||||
|
||||
struct Overlay<'a, 'b, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
{
|
||||
float: &'a mut Float<'b, Message, Theme, Renderer>,
|
||||
state: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
viewport: Rectangle,
|
||||
transformation: Transformation,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> core::Overlay<Message, Theme, Renderer>
|
||||
for Overlay<'_, '_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: Catalog,
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node {
|
||||
let bounds = self.layout.bounds() * self.transformation;
|
||||
|
||||
layout::Node::new(bounds.size()).move_to(bounds.position())
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
_layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let inverse = self.transformation.inverse();
|
||||
|
||||
self.float.content.as_widget_mut().update(
|
||||
self.state,
|
||||
event,
|
||||
self.layout,
|
||||
cursor * inverse,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
&(self.viewport * inverse),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
_layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let bounds = self.layout.bounds();
|
||||
let inverse = self.transformation.inverse();
|
||||
|
||||
renderer.with_layer(self.viewport, |renderer| {
|
||||
renderer.with_transformation(self.transformation, |renderer| {
|
||||
{
|
||||
let style = theme.style(&self.float.class);
|
||||
|
||||
if style.shadow.color.a > 0.0 {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: bounds.shrink(1.0),
|
||||
shadow: style.shadow,
|
||||
border: border::rounded(
|
||||
style.shadow_border_radius,
|
||||
),
|
||||
snap: false,
|
||||
},
|
||||
style.shadow.color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.float.content.as_widget().draw(
|
||||
self.state,
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
self.layout,
|
||||
cursor * inverse,
|
||||
&(self.viewport * inverse),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
if !cursor.is_over(layout.bounds()) {
|
||||
return mouse::Interaction::None;
|
||||
}
|
||||
|
||||
let inverse = self.transformation.inverse();
|
||||
|
||||
self.float.content.as_widget().mouse_interaction(
|
||||
self.state,
|
||||
self.layout,
|
||||
cursor * inverse,
|
||||
&(self.viewport * inverse),
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn index(&self) -> f32 {
|
||||
self.float.scale * 0.5
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
_layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.float.content.as_widget_mut().overlay(
|
||||
self.state,
|
||||
self.layout,
|
||||
renderer,
|
||||
&(self.viewport * self.transformation.inverse()),
|
||||
self.transformation.translation(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme catalog of a [`Float`].
|
||||
///
|
||||
/// All themes that can be used with [`Float`]
|
||||
/// must implement this trait.
|
||||
pub trait Catalog {
|
||||
/// The item class of the [`Catalog`].
|
||||
type Class<'a>;
|
||||
|
||||
/// The default class produced by the [`Catalog`].
|
||||
fn default<'a>() -> Self::Class<'a>;
|
||||
|
||||
/// The [`Style`] of a class with the given status.
|
||||
fn style(&self, class: &Self::Class<'_>) -> Style;
|
||||
}
|
||||
|
||||
/// A styling function for a [`Float`].
|
||||
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
|
||||
|
||||
impl Catalog for crate::Theme {
|
||||
type Class<'a> = StyleFn<'a, Self>;
|
||||
|
||||
fn default<'a>() -> Self::Class<'a> {
|
||||
Box::new(|_| Style::default())
|
||||
}
|
||||
|
||||
fn style(&self, class: &Self::Class<'_>) -> Style {
|
||||
class(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of a [`Float`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Style {
|
||||
/// The [`Shadow`] of the [`Float`].
|
||||
pub shadow: Shadow,
|
||||
/// The border radius of the shadow.
|
||||
pub shadow_border_radius: border::Radius,
|
||||
}
|
||||
-415
@@ -1,415 +0,0 @@
|
||||
//! Distribute content on a grid.
|
||||
use crate::core::layout::{self, Layout};
|
||||
use crate::core::mouse;
|
||||
use crate::core::overlay;
|
||||
use crate::core::renderer;
|
||||
use crate::core::widget::{Operation, Tree};
|
||||
use crate::core::{
|
||||
Clipboard, Element, Event, Length, Pixels, Rectangle, Shell, Size, Vector,
|
||||
Widget,
|
||||
};
|
||||
|
||||
/// A container that distributes its contents on a responsive grid.
|
||||
pub struct Grid<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> {
|
||||
spacing: f32,
|
||||
columns: Constraint,
|
||||
width: Option<Pixels>,
|
||||
height: Sizing,
|
||||
children: Vec<Element<'a, Message, Theme, Renderer>>,
|
||||
}
|
||||
|
||||
enum Constraint {
|
||||
MaxWidth(Pixels),
|
||||
Amount(usize),
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Grid<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
/// Creates an empty [`Grid`].
|
||||
pub fn new() -> Self {
|
||||
Self::from_vec(Vec::new())
|
||||
}
|
||||
|
||||
/// Creates a [`Grid`] with the given capacity.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self::from_vec(Vec::with_capacity(capacity))
|
||||
}
|
||||
|
||||
/// Creates a [`Grid`] with the given elements.
|
||||
pub fn with_children(
|
||||
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
let iterator = children.into_iter();
|
||||
|
||||
Self::with_capacity(iterator.size_hint().0).extend(iterator)
|
||||
}
|
||||
|
||||
/// Creates a [`Grid`] from an already allocated [`Vec`].
|
||||
pub fn from_vec(
|
||||
children: Vec<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
spacing: 0.0,
|
||||
columns: Constraint::Amount(3),
|
||||
width: None,
|
||||
height: Sizing::AspectRatio(1.0),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the spacing _between_ cells in the [`Grid`].
|
||||
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
|
||||
self.spacing = amount.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Grid`] in [`Pixels`].
|
||||
///
|
||||
/// By default, a [`Grid`] will [`Fill`] its parent.
|
||||
///
|
||||
/// [`Fill`]: Length::Fill
|
||||
pub fn width(mut self, width: impl Into<Pixels>) -> Self {
|
||||
self.width = Some(width.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Grid`].
|
||||
///
|
||||
/// By default, a [`Grid`] uses a cell aspect ratio of `1.0` (i.e. squares).
|
||||
pub fn height(mut self, height: impl Into<Sizing>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the amount of columns in the [`Grid`].
|
||||
pub fn columns(mut self, column: usize) -> Self {
|
||||
self.columns = Constraint::Amount(column);
|
||||
self
|
||||
}
|
||||
|
||||
/// Makes the amount of columns dynamic in the [`Grid`], never
|
||||
/// exceeding the provided `max_width`.
|
||||
pub fn fluid(mut self, max_width: impl Into<Pixels>) -> Self {
|
||||
self.columns = Constraint::MaxWidth(max_width.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an [`Element`] to the [`Grid`].
|
||||
pub fn push(
|
||||
mut self,
|
||||
child: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
self.children.push(child.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an element to the [`Grid`], if `Some`.
|
||||
pub fn push_maybe(
|
||||
self,
|
||||
child: Option<impl Into<Element<'a, Message, Theme, Renderer>>>,
|
||||
) -> Self {
|
||||
if let Some(child) = child {
|
||||
self.push(child)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Extends the [`Grid`] with the given children.
|
||||
pub fn extend(
|
||||
self,
|
||||
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
children.into_iter().fold(self, Self::push)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Renderer> Default for Grid<'_, Message, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer: crate::core::Renderer>
|
||||
FromIterator<Element<'a, Message, Theme, Renderer>>
|
||||
for Grid<'a, Message, Theme, Renderer>
|
||||
{
|
||||
fn from_iter<
|
||||
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
|
||||
>(
|
||||
iter: T,
|
||||
) -> Self {
|
||||
Self::with_children(iter)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Grid<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: crate::core::Renderer,
|
||||
{
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
self.children.iter().map(Tree::new).collect()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(&self.children);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self
|
||||
.width
|
||||
.map(|pixels| Length::Fixed(pixels.0))
|
||||
.unwrap_or(Length::Fill),
|
||||
height: match self.height {
|
||||
Sizing::AspectRatio(_) => Length::Shrink,
|
||||
Sizing::EvenlyDistribute(length) => length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let size = self.size();
|
||||
let limits = limits.width(size.width).height(size.height);
|
||||
let available = limits.max();
|
||||
|
||||
let cells_per_row = match self.columns {
|
||||
// width = n * (cell + spacing) - spacing, given n > 0
|
||||
Constraint::MaxWidth(pixels) => ((available.width + self.spacing)
|
||||
/ (pixels.0 + self.spacing))
|
||||
.ceil() as usize,
|
||||
Constraint::Amount(amount) => amount,
|
||||
};
|
||||
|
||||
if self.children.is_empty() || cells_per_row == 0 {
|
||||
return layout::Node::new(limits.resolve(
|
||||
size.width,
|
||||
size.height,
|
||||
Size::ZERO,
|
||||
));
|
||||
}
|
||||
|
||||
let cell_width = (available.width
|
||||
- self.spacing * (cells_per_row - 1) as f32)
|
||||
/ cells_per_row as f32;
|
||||
|
||||
let cell_height = match self.height {
|
||||
Sizing::AspectRatio(ratio) => Some(cell_width / ratio),
|
||||
Sizing::EvenlyDistribute(Length::Shrink) => None,
|
||||
Sizing::EvenlyDistribute(_) => {
|
||||
let total_rows = self.children.len().div_ceil(cells_per_row);
|
||||
Some(
|
||||
(available.height - self.spacing * (total_rows - 1) as f32)
|
||||
/ total_rows as f32,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let cell_limits = layout::Limits::new(
|
||||
Size::new(cell_width, cell_height.unwrap_or(0.0)),
|
||||
Size::new(cell_width, cell_height.unwrap_or(available.height)),
|
||||
);
|
||||
|
||||
let mut nodes = Vec::with_capacity(self.children.len());
|
||||
let mut x = 0.0;
|
||||
let mut y = 0.0;
|
||||
let mut row_height = 0.0f32;
|
||||
|
||||
for (i, (child, tree)) in
|
||||
self.children.iter_mut().zip(&mut tree.children).enumerate()
|
||||
{
|
||||
let node = child
|
||||
.as_widget_mut()
|
||||
.layout(tree, renderer, &cell_limits)
|
||||
.move_to((x, y));
|
||||
|
||||
let size = node.size();
|
||||
|
||||
x += size.width + self.spacing;
|
||||
row_height = row_height.max(size.height);
|
||||
|
||||
if (i + 1) % cells_per_row == 0 {
|
||||
y += cell_height.unwrap_or(row_height) + self.spacing;
|
||||
x = 0.0;
|
||||
row_height = 0.0;
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
if x == 0.0 {
|
||||
y -= self.spacing;
|
||||
} else {
|
||||
y += cell_height.unwrap_or(row_height);
|
||||
}
|
||||
|
||||
layout::Node::with_children(Size::new(available.width, y), nodes)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.container(None, layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.children
|
||||
.iter_mut()
|
||||
.zip(&mut tree.children)
|
||||
.zip(layout.children())
|
||||
.for_each(|((child, state), layout)| {
|
||||
child
|
||||
.as_widget_mut()
|
||||
.operate(state, layout, renderer, operation);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
for ((child, tree), layout) in self
|
||||
.children
|
||||
.iter_mut()
|
||||
.zip(&mut tree.children)
|
||||
.zip(layout.children())
|
||||
{
|
||||
child.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.children
|
||||
.iter()
|
||||
.zip(&tree.children)
|
||||
.zip(layout.children())
|
||||
.map(|((child, tree), layout)| {
|
||||
child
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
})
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if let Some(viewport) = layout.bounds().intersection(viewport) {
|
||||
for ((child, tree), layout) in self
|
||||
.children
|
||||
.iter()
|
||||
.zip(&tree.children)
|
||||
.zip(layout.children())
|
||||
.filter(|(_, layout)| layout.bounds().intersects(&viewport))
|
||||
{
|
||||
child.as_widget().draw(
|
||||
tree, renderer, theme, style, layout, cursor, &viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
overlay::from_children(
|
||||
&mut self.children,
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Grid<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: crate::core::Renderer + 'a,
|
||||
{
|
||||
fn from(row: Grid<'a, Message, Theme, Renderer>) -> Self {
|
||||
Self::new(row)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sizing strategy of a [`Grid`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Sizing {
|
||||
/// The [`Grid`] will ensure each cell follows the given aspect ratio and the
|
||||
/// total size will be the sum of the cells and the spacing between them.
|
||||
///
|
||||
/// The ratio is the amount of horizontal pixels per each vertical pixel of a cell
|
||||
/// in the [`Grid`].
|
||||
AspectRatio(f32),
|
||||
|
||||
/// The [`Grid`] will evenly distribute the space available in the given [`Length`]
|
||||
/// for each cell.
|
||||
EvenlyDistribute(Length),
|
||||
}
|
||||
|
||||
impl From<f32> for Sizing {
|
||||
fn from(height: f32) -> Self {
|
||||
Self::EvenlyDistribute(Length::from(height))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Length> for Sizing {
|
||||
fn from(height: Length) -> Self {
|
||||
Self::EvenlyDistribute(height)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`Sizing`] strategy that maintains the given aspect ratio.
|
||||
pub fn aspect_ratio(
|
||||
width: impl Into<Pixels>,
|
||||
height: impl Into<Pixels>,
|
||||
) -> Sizing {
|
||||
Sizing::AspectRatio(width.into().0 / height.into().0)
|
||||
}
|
||||
-2107
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user