Compare commits

..
Author SHA1 Message Date
Codex-ytr83r 3aa80de89c Fix attachment overshoot and semantic trace port geometry 2026-09-22 18:13:07 -07:00
Codex-ytr83r 79cc9d5551 Support per-view connection boundary attachments in ordinary and state diagrams 2026-09-22 17:51:13 -07:00
sorokin.oleg.edu 3f9e6a18af Record portable ordinary-text native acceptance evidence 2026-09-13 07:38:59 -07:00
sorokin.oleg.edu 7836a93edb Record portable ordinary-text integration gate evidence 2026-09-13 07:31:29 -07:00
sorokin.oleg.edu f714a25bbc Retire the obsolete source close event and use current drawer controls in tests 2026-09-13 07:10:23 -07:00
sorokin.oleg.edu cb941367d7 Keep portable evidence hashes valid after Git newline normalization 2026-09-13 06:55:39 -07:00
sorokin.oleg.edu 96b756bece Publish the reviewed ordinary and Text compatibility checkpoint 2026-09-13 06:54:43 -07:00
sorokin.oleg.edu e624d5b9c4 Align the teaching source and language contract with compact defaults 2026-09-13 06:46:08 -07:00
sorokin.oleg.edu d7c065991d Compose ordinary source defaults with the reviewed Text workspace 2026-09-13 06:33:04 -07:00
sorokin.oleg.edu 136ed6e586 Preserve original review inventory bytes in Git 2026-09-13 05:37:32 -07:00
sorokin.oleg.edu 46124ce76c Record Text integration source review and recovery correction 2026-09-13 05:36:04 -07:00
sorokin.oleg.edu 554cfbd9fe Preserve diagnostic source authority when recovery removes save notice 2026-09-13 05:06:50 -07:00
sorokin.oleg.edu 92ff99eb54 Integrate diagnostic decorations and current-source Text input boundaries 2026-09-13 04:47:30 -07:00
sorokin.oleg.edu 16904398db Align root retention assertions with exact current-source drawer ownership 2026-09-13 04:12:18 -07:00
sorokin.oleg.edu 74542ae2c1 Compose compact Text workspace with guarded root navigation 2026-09-13 04:10:27 -07:00
226 changed files with 54248 additions and 1407 deletions
+3
View File
@@ -86,3 +86,6 @@ harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace
harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol
harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol
harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
# Preserve the pinned Iced dependency bytes and upstream manifest.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
Generated
-2
View File
@@ -1567,8 +1567,6 @@ 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",
+4
View File
@@ -1,7 +1,11 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
exclude = ["crates/vendor/iced_widget"]
resolver = "2"
[patch.crates-io]
iced_widget = { path = "crates/vendor/iced_widget" }
[workspace.package]
version = "0.2.0"
edition = "2024"
+2 -2
View File
@@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Repor
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
// 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()
@@ -0,0 +1,27 @@
//! Read and validate a saved acceptance fixture without rewriting its source.
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
use oa_core::{persistence, validate_candidate};
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let bytes = serde_json::to_vec_pretty(&project)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.write_all(b"\n")?;
output.sync_all()?;
println!(
"Validated {} objects and {} views; saved a complete Project snapshot",
project.identities().len(),
project.diagrams.len()
);
Ok(())
}
+49 -15
View File
@@ -3,6 +3,8 @@
mod semantic_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
use crate::source_defaults::{self, Context as DefaultContext};
use crate::*;
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fmt};
@@ -645,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
source.len()
}
/// Canonical output uses persistent aliases and explicit identities. Typed
/// nested records are ordinary language fields; JSON documents are not embedded.
/// Canonical output uses persistent aliases and explicit identities, omitting
/// only values supplied by source defaults. Typed nested records are ordinary
/// language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
project.alias,
quote(&project.name),
project.id,
quote(&project.description)
);
for (_, declaration) in format_declarations(project) {
if !project.description.is_empty() {
out.push_str(&std::format!(
" description {};\n",
quote(&project.description)
));
}
out.push('\n');
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
out.push_str(&declaration);
}
out.push_str("}\n");
out
}
/// The same typed declaration writer is used by flat and modular source output.
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
/// Shared typed writer for canonical output and lossless editing. Complete edit
/// comparisons retain defaults; emitted declarations and value fragments apply
/// only the omission rules valid in their selected source context.
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
let aliases = project
.identities()
.into_iter()
@@ -675,6 +686,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
fields.insert("description".into(), Value::String(e.description.clone()));
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
write_declaration(
&mut out,
&element_word(e.kind(), e.layer()),
@@ -690,6 +702,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut out = String::new();
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
fields.insert("description".into(), Value::String(r.description.clone()));
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
write_declaration(
&mut out,
&relation_word(r.kind()),
@@ -705,6 +718,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut out = String::new();
let mut fields = record_fields(d);
fields.remove("nodes");
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
write_declaration(
&mut out,
"diagram",
@@ -719,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut out = String::new();
let mut fields = record_fields(n);
fields.insert("diagram".into(), Value::String(d.id.to_string()));
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
declarations.push((n.id, out));
}
@@ -731,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
&a.alias,
Some(&a.name),
a.id,
&record_fields(a),
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
&aliases,
);
declarations.push((a.id, out));
@@ -744,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
&t.alias,
Some(&t.name),
t.id,
&record_fields(t),
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
&aliases,
);
declarations.push((t.id, out));
@@ -1742,7 +1757,9 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let defaults = if legacy {
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Element(kind))
} else if legacy {
crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
@@ -1799,7 +1816,12 @@ pub(crate) fn resolve_declarations(
let mut body =
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
.unwrap();
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Relation(kind))
} else {
payload(&body)
};
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
replace_payload(&mut body, merged);
let body = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
@@ -1824,7 +1846,10 @@ pub(crate) fn resolve_declarations(
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Diagram)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
record.insert("nodes".into(), serde_json::json!({}));
@@ -1839,7 +1864,10 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error("Missing required field 'element'.".into()));
}
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Node)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
let node: NodePlacement = serde_json::from_value(Value::Object(record))
@@ -1851,7 +1879,10 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error("Missing required field 'owner'.".into()));
}
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Artifact)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let artifact = serde_json::from_value(Value::Object(record))
@@ -1867,7 +1898,10 @@ pub(crate) fn resolve_declarations(
}
}
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Trace)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = if project.schema_version < 5 {
+1
View File
@@ -11,6 +11,7 @@ pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
mod source_defaults;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
+34
View File
@@ -1502,11 +1502,45 @@ pub struct Diagram {
#[serde(deny_unknown_fields)]
pub struct EdgeRoute {
pub edge: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_attachment: Option<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// Presentation preference on one end of a connection. Real ports take precedence.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeAttachment {
pub side: PortAnchorSide,
pub offset: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeEnd {
Source,
Target,
}
impl EdgeRoute {
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
match end {
EdgeEnd::Source => self.source_attachment = attachment,
EdgeEnd::Target => self.target_attachment = attachment,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeLabel {
+132 -38
View File
@@ -1,7 +1,12 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use crate::{
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
RelationBody, RelationKind, Size,
};
mod attachments;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
attachments: [Option<EdgeAttachment>; 2],
}
#[derive(Debug, Clone, PartialEq)]
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
CoincidentAttachments,
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
@@ -181,6 +188,9 @@ pub enum RouteWarning {
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CoincidentAttachments => f.write_str(
"The two attachments coincide; move either end to separate the loop tips.",
),
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
@@ -374,7 +384,14 @@ struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
),
}];
}
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
if let Some(attachment) = attachment {
return vec![attachments::endpoint(project, diagram, node, attachment)];
}
[
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
.collect()
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -507,6 +516,12 @@ fn route_diagram_impl(
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -517,7 +532,10 @@ fn route_diagram_impl(
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins && cached.custom_suspended == custom_suspended {
if cached.pins == pins
&& cached.attachments == attachments
&& cached.custom_suspended == custom_suspended
{
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
@@ -543,9 +561,22 @@ fn route_diagram_impl(
}
let (points, pin_lengths, failure) = route_edge(
edge,
source,
target,
view,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
&pins,
&obstacles,
&previous,
@@ -574,6 +605,7 @@ fn route_diagram_impl(
warnings,
custom_suspended,
search_budget_before,
attachments,
},
);
}
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
xs: Vec<f32>,
ys: Vec<f32>,
}
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
let normal = pos(
direction(a.stub.x - a.point.x),
direction(a.stub.y - a.point.y),
);
let tangent = pos(-normal.y, normal.x);
let candidates: Vec<_> = [36., 72., 144., 288.]
.into_iter()
.flat_map(|extent| {
[-1., 1.].into_iter().map(move |side| {
[
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
pos(
a.stub.x + (normal.x + tangent.x * side) * extent,
a.stub.y + (normal.y + tangent.y * side) * extent,
),
pos(
a.stub.x + tangent.x * side * extent,
a.stub.y + tangent.y * side * extent,
),
]
})
})
.collect();
// These are computed loop guards, not authored hard pins. Prefer a clear
// orientation/size before asking the bounded router to connect them.
candidates
.iter()
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
.or_else(|| {
candidates.iter().find(|p| {
p.iter()
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
})
})
.copied()
.unwrap_or(candidates[0])
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
source_ends: &[Endpoint],
target_ends: &[Endpoint],
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
// Coincident authored tips are valid presentation, including on self loops.
// Preserve them and add an external detour; never index an empty candidate
// list or turn the connection into an out-and-back degenerate stub.
let coincident = edge.source == edge.target
&& source_ends.len() == 1
&& target_ends.len() == 1
&& source_ends[0].point == target_ends[0].point
&& source_ends[0].stub == target_ends[0].stub;
let authored_pin_count = pins.len();
let mut loop_pins = Vec::new();
if coincident {
let a = source_ends[0];
loop_pins.extend_from_slice(pins);
loop_pins.extend(coincident_detour(a, obstacles));
}
let pins = if coincident { &loop_pins[..] } else { pins };
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
for &a in source_ends {
for &b in target_ends {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
continue;
}
@@ -729,10 +818,9 @@ fn route_edge(
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
if best.as_ref().is_some_and(|(cost, _, _)| {
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
}) {
continue;
}
if obstacles
@@ -748,7 +836,7 @@ fn route_edge(
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut distance = manhattan(a.point, a.stub);
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
@@ -792,15 +880,20 @@ fn route_edge(
break;
}
}
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
if let Some((_, points, mut pin_lengths)) = best {
pin_lengths.truncate(authored_pin_count);
return (
points,
pin_lengths,
coincident.then_some(RouteWarning::CoincidentAttachments),
);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut distance = manhattan(a.point, a.stub);
let mut start = a.stub;
for (index, end) in pins
.iter()
@@ -833,6 +926,7 @@ fn route_edge(
start = end;
}
points.push(b.point);
pin_lengths.truncate(authored_pin_count);
(
points,
pin_lengths,
+160
View File
@@ -0,0 +1,160 @@
//! One boundary definition shared by persisted routing and attachment gestures.
use super::*;
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
/// Semantic authority, independent of whether projection currently exposes a port.
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
let endpoint = if let Some(relation) = project.relations.get(&edge) {
match (&relation.body, end) {
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
(_, EdgeEnd::Source) => relation.source(),
(_, EdgeEnd::Target) => relation.target(),
}
} else {
let trace = project.traces.get(&edge)?;
match end {
EdgeEnd::Source => trace.oa,
EdgeEnd::Target => trace.sa,
}
};
project
.elements
.get(&endpoint)
.filter(|e| e.kind() == Kind::Port)
.map(|_| endpoint)
}
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
/// measured over the complete projected bounding side, including its corners.
/// Terminate's X uses its outermost stroke intersection rather than a contour.
pub fn attachment_point(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: &EdgeAttachment,
) -> Position {
let vertical = matches!(
attachment.side,
PortAnchorSide::Left | PortAnchorSide::Right
);
let (extent, depth) = if vertical {
(node.size.height, node.size.width)
} else {
(node.size.width, node.size.height)
};
let along = attachment.offset.clamp(0., 1.) * extent;
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
let kind = diagram
.projection
.state_machine()
.and_then(|_| project.elements.get(&node.element))
.map(|e| e.kind());
let inset = match kind {
Some(Kind::Choice) => depth * 0.5 * t.abs(),
Some(Kind::Terminate) => {
depth
* attachment
.offset
.clamp(0., 1.)
.min(1. - attachment.offset.clamp(0., 1.))
}
Some(
Kind::Initial
| Kind::Junction
| Kind::Final
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint,
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
_ => {
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
let corner = (radius - along.min(extent - along)).max(0.);
radius - (radius * radius - corner * corner).max(0.).sqrt()
}
};
match attachment.side {
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
PortAnchorSide::Right => pos(
node.position.x + node.size.width - inset,
node.position.y + along,
),
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
PortAnchorSide::Bottom => pos(
node.position.x + along,
node.position.y + node.size.height - inset,
),
}
}
/// Choose a cardinal bounding side, then map its fraction onto the painted
/// contour. Contour distances cannot distinguish the sides of a circle at its
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
/// rounds to hundredths: source/API values retain their complete precision.
pub fn attachment_from_point(
node: &ProjectedNode,
cursor: Position,
previous: Option<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_cursor,
)
};
let mut side = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
.unwrap();
let hysteresis = hysteresis
.max(0.)
.min(node.size.width.min(node.size.height) * 0.2);
if let Some(old) = previous
&& distance(old) <= distance(side) + hysteresis
{
side = old;
}
let mut attachment = candidate(side);
attachment.offset = (attachment.offset * 100.).round() / 100.;
attachment
}
pub(super) fn endpoint(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: EdgeAttachment,
) -> Endpoint {
let point = attachment_point(project, diagram, node, &attachment);
let stub = match attachment.side {
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
};
Endpoint { point, stub }
}
+399
View File
@@ -0,0 +1,399 @@
//! Ordinary source defaults shared by current parsing and declaration rendering.
//!
//! These are source omission defaults, not graphical creation defaults. The
//! policy operates on complete typed declaration maps. Required named fields
//! and selected constructor payloads remain distinct from omission defaults.
//! Historical versions use their existing frozen parser paths. Semantic baseline
//! history is never traversed or normalized here.
use crate::{Kind, RelationKind};
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Context {
Element(Kind),
Relation(RelationKind),
Diagram,
Node,
Artifact,
Trace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
CompleteForEdit,
Compact,
/// Internal lossless-edit fragments: retain every top-level value while
/// compacting its selected constructor's optional nested fields.
CompactValues,
}
/// Top-level field inventory for the current ordinary reader. Callers must
/// check required fields before merging; null entries only admit their names.
/// This never supplies defaults inside a selected constructor.
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
let mut fields = declaration_defaults(context);
let required: &[&str] = match context {
Context::Element(kind) => {
fields.remove("description");
fields.remove("parent");
if kind == Kind::Port {
&["contract"]
} else {
&[]
}
}
Context::Relation(kind) => {
fields.remove("description");
match kind {
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
RelationKind::FunctionalExchange => {
&["source", "target", "source_port", "target_port"]
}
_ => &["source", "target"],
}
}
Context::Diagram => &[],
Context::Node => &["element"],
Context::Artifact => &["owner"],
Context::Trace => &["oa", "sa"],
};
for name in required {
fields.insert((*name).into(), Value::Null);
}
fields
}
pub(crate) fn fields(
context: Context,
input: &Map<String, Value>,
mode: Mode,
) -> Map<String, Value> {
let mut result = input.clone();
restore_serialization_skips(context, &mut result);
if mode != Mode::CompleteForEdit {
compact_nested(context, &mut result);
}
if mode == Mode::Compact {
// Compact a composite and its declared default by the same nested rules
// before whole-field comparison. This also makes compact input stable:
// e.g. String{minimum_length:0} still denotes the default definition.
let mut defaults = declaration_defaults(context);
compact_nested(context, &mut defaults);
omit_defaults(&mut result, &defaults);
}
result
}
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
match context {
Context::Element(Kind::Occurrence) => {
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
exchange
.entry("operation".to_owned())
.or_insert(Value::Null);
}
}
Context::Diagram => {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
route
.entry("source_attachment".to_owned())
.or_insert(Value::Null);
route
.entry("target_attachment".to_owned())
.or_insert(Value::Null);
}
}
}
Context::Node => {
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
}
Context::Trace => {
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
}
}
_ => {}
}
}
fn declaration_defaults(context: Context) -> Map<String, Value> {
let mut defaults = match context {
Context::Element(kind) => element_defaults(kind),
Context::Relation(kind) => relation_defaults(kind),
Context::Diagram => object(json!({
"description": "", "layer": "Oa", "projection": "Overview",
"routes": [], "view_options": {
"hidden_edges": [], "relation_kinds": "All",
"work_categories": "All", "participant_categories": "All",
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
}
})),
Context::Node => object(json!({
"position": {"x": 0.0, "y": 0.0},
"size": {"width": 224.0, "height": 94.0},
"collapsed": false, "port_anchors": []
})),
Context::Artifact => object(json!({
"description": "", "relation": "References", "uri": "",
"revision": null, "fragment": null
})),
Context::Trace => object(json!({
"description": "", "kind": "Refinement", "baseline": null
})),
};
match context {
Context::Element(_) => {
defaults.insert("description".into(), json!(""));
defaults.insert("parent".into(), Value::Null);
}
Context::Relation(_) => {
defaults.insert("description".into(), json!(""));
}
_ => {}
}
defaults
}
fn element_defaults(kind: Kind) -> Map<String, Value> {
if let Some(state_kind) = kind.state_kind() {
// The declaration selects this discriminator; no GUI factory is used.
return object(json!({
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
}));
}
match kind {
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
Kind::ExchangeItem => object(json!({
"kind": "Information", "mechanism": "Unspecified", "supertype": null
})),
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
Kind::DataType => object(json!({
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
"default": null, "unit": ""
})),
Kind::DataField => object(json!({
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
"direction": "Unspecified", "storage": "Value", "default": null
})),
Kind::Interface => object(json!({"supertype": null})),
// Port.contract is required, even when its explicit value is Legacy.
Kind::Port => object(json!({"legacy": null})),
Kind::Scenario => object(json!({
"viewpoint": "MixedLegacy", "context": null,
"precondition": "", "postcondition": "", "steps": []
})),
Kind::Lifeline => object(json!({"represented": null})),
Kind::Occurrence => object(json!({
"body": {"Exchange": {
"exchange": null, "sender": null, "receiver": null,
"message_kind": "Asynchronous", "reply_to": null,
"values": [], "operation": null
}},
"precondition": "", "postcondition": ""
})),
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
// Unit-body declarations have no additional omission defaults. A future
// Kind reaches this branch without gaining speculative payload rules.
_ => Map::new(),
}
}
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
match kind {
RelationKind::InterfaceItemAllocation => object(json!({
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
})),
RelationKind::DataAssociation => object(json!({
"source_role": "", "target_role": "",
"source_minimum": 0, "target_minimum": 0,
"source_maximum": null, "target_maximum": null,
"source_navigable": false, "target_navigable": false
})),
RelationKind::Interaction | RelationKind::FunctionalExchange => {
object(json!({"items": []}))
}
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
RelationKind::SequenceLink => object(json!({"guard": ""})),
RelationKind::StateTransition => object(json!({
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
"triggers": [], "effects": []
})),
RelationKind::StateAction => object(json!({"phase": "Do"})),
// source/target, FunctionalExchange ports and ContextualLink's owner,
// endpoints and purpose are required, despite factory placeholder values.
_ => Map::new(),
}
}
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
match context {
Context::Element(Kind::DataType) => compact_data_definition(fields),
Context::Element(Kind::Port) => {
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
omit_nulls(legacy, &["interface"]);
}
// Every selected PortContract payload field remains required.
}
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
Context::Diagram => {
if let Some(options) = fields
.get_mut("view_options")
.and_then(Value::as_object_mut)
{
omit_defaults(
options,
&object(json!({
"hidden_edges": [], "relation_kinds": "All",
"work_categories": "All", "participant_categories": "All",
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
})),
);
}
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes {
if let Some(route) = route.as_object_mut() {
omit_defaults(
route,
&object(
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
),
);
}
}
}
// Route entries, EdgeLabel coordinates, projection machine IDs, and
// Only([])/Any([]) selectors remain even when their values are empty.
}
Context::Trace => {
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
omit_nulls(baseline, &["semantic"]);
}
// No recursion through snapshots or semantic historical facets.
}
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
// contextual endpoints/purposes, and other required records stay intact.
_ => {}
}
}
fn compact_data_definition(fields: &mut Map<String, Value>) {
for tag in ["Integer", "Real"] {
if let Some(payload) = constructor_payload(fields, "definition", tag) {
omit_nulls(payload, &["minimum", "maximum"]);
}
}
if let Some(payload) = constructor_payload(fields, "definition", "String") {
omit_nulls(payload, &["maximum_length"]);
// minimum_length is required inside a selected String constructor.
}
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
omit_nulls(payload, &["supertype"]);
}
// Boolean has no payload; Enumeration.literals remains required even [].
}
fn compact_occurrence(fields: &mut Map<String, Value>) {
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
omit_defaults(
exchange,
&object(json!({
"exchange": null, "sender": null, "receiver": null,
"message_kind": "Asynchronous", "reply_to": null,
"values": [], "operation": null
})),
);
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
omit_nulls(operation, &["allocation"]);
// Keep the containing operation field even if its record becomes {}:
// explicit unresolved operation intent is not an absent operation.
}
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
for value in values {
if let Some(message_value) = value.as_object_mut() {
omit_defaults(message_value, &object(json!({"path": []})));
}
}
}
// MessageKind::Timer.expression, MessageValue.field/expression remain.
}
for (tag, optional) in [
("Action", &["action", "lifeline"][..]),
("State", &["state", "lifeline"][..]),
("SequenceReference", &["sequence"][..]),
("ScenarioUse", &["scenario"][..]),
] {
if let Some(payload) = constructor_payload(fields, "body", tag) {
omit_nulls(payload, optional);
}
}
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
// Branch.guard/occurrences are required, including empty strings/lists.
}
fn constructor_payload<'a>(
fields: &'a mut Map<String, Value>,
key: &str,
tag: &str,
) -> Option<&'a mut Map<String, Value>> {
let wrapper = fields.get_mut(key)?.as_object_mut()?;
if wrapper.len() != 1 {
return None;
}
wrapper.get_mut(tag)?.as_object_mut()
}
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
for name in names {
if fields.get(*name).is_some_and(Value::is_null) {
fields.remove(*name);
}
}
}
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
for (name, default) in defaults {
if fields.get(name).is_some_and(|value| same(value, default)) {
fields.remove(name);
}
}
}
// Comparison recurses only to compare a known field with its exact declared
// default. It never traverses arbitrary data looking for values to remove.
fn same(value: &Value, default: &Value) -> bool {
match (value, default) {
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
}
(Value::Number(a), Value::Number(b)) => {
// serde_json numeric equality alone can equate -0.0 with +0.0.
// Extracted f32 coordinates are represented exactly as f64 here.
a == b
&& match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
_ => true,
}
}
_ => value == default,
}
}
fn object(value: Value) -> Map<String, Value> {
match value {
Value::Object(fields) => fields,
_ => unreachable!("literal source default is an object"),
}
}
#[cfg(test)]
#[path = "source_defaults_tests.rs"]
mod tests;
+718
View File
@@ -0,0 +1,718 @@
//! Independent literal oracles for the production source-default policy.
use super::{self as policy, Context, Mode};
use crate::{Kind, RelationKind};
use serde_json::{Value, json};
#[test]
fn complete_comparison_keeps_populated_serializer_skip_values() {
for (context, input) in [
(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
),
(
Context::Node,
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
),
(
Context::Trace,
json!({"baseline":{"semantic":{"historical":"payload"}}}),
),
] {
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
}
}
#[test]
fn compact_value_fragments_keep_selected_constructor_requirements() {
for (context, input, expected) in [
(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
),
(
Context::Element(Kind::Event),
json!({"body":{"Signal":{"items":[]}}}),
json!({"body":{"Signal":{"items":[]}}}),
),
(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
),
(
Context::Element(Kind::Occurrence),
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
),
] {
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
}
}
#[test]
fn source_scenario_default_is_independent_of_graphical_creation() {
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
let project = language::parse(
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
)
.unwrap();
for element in project.elements.values() {
assert!(matches!(
element.body.shared(),
Some(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
..
})
));
assert!(!matches!(
ElementBody::new(Kind::Scenario, element.layer())
.unwrap()
.shared(),
Some(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
..
})
));
}
let source = language::format(&project);
assert!(!source.contains("description"));
assert!(!source.contains("parent"));
assert!(!source.contains("viewpoint"));
assert_eq!(language::parse(&source).unwrap(), project);
let mut typed = project;
for element in typed.elements.values_mut() {
let viewpoint = if element.layer() == Layer::Oa {
ScenarioViewpoint::OperationalActivity
} else {
ScenarioViewpoint::Functional
};
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
viewpoint,
context: None,
precondition: String::new(),
postcondition: String::new(),
steps: vec![],
};
}
let source = language::format(&typed);
assert!(source.contains("viewpoint OperationalActivity;"));
assert!(source.contains("viewpoint Functional;"));
assert_eq!(language::parse(&source).unwrap(), typed);
}
#[test]
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
for (declaration, message) in [
(
"oa_port p0 \"Port\" {}",
"Missing required field 'contract'",
),
(
"allocation r \"R\" { source @owner; }",
"Missing required field 'target'",
),
(
"functional_exchange r \"R\" { source @owner; target @work; }",
"Missing required field 'source_port'",
),
(
"contextual_link r \"R\" {}",
"Missing required field 'owner'",
),
("node n { diagram @d; }", "Missing required field 'element'"),
("artifact a \"A\" {}", "Missing required field 'owner'"),
("trace t \"T\" {}", "Missing required field 'oa'"),
(
"oa_state s \"S\" { kind Initial; }",
"Declaration kind disagrees",
),
(
"oa_activity a \"A\" { unknown true; }",
"Unknown field 'unknown'",
),
(
"oa_data_type t \"T\" { definition { String {}; }; }",
"minimum_length",
),
(
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
"bindings",
),
] {
let source = format!(
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
);
let errors = crate::language::parse(&source).unwrap_err();
assert!(
errors.iter().any(|error| error.message.contains(message)),
"{declaration}: {errors:?}"
);
}
}
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
}
fn compact(context: Context, input: Value, expected: Value) {
let original = input.clone();
let result = rendered(context, &input, Mode::Compact);
assert_eq!(result, expected, "context {context:?}");
assert_eq!(input, original, "the input remains immutable");
assert_eq!(
rendered(context, &result, Mode::Compact),
result,
"compact policy is idempotent"
);
}
#[test]
fn complete_comparison_restores_only_the_three_known_skips() {
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
let mut complete = exchange.clone();
complete["body"]["Exchange"]["operation"] = Value::Null;
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&exchange,
Mode::CompleteForEdit
),
complete
);
assert_eq!(
rendered(
Context::Node,
&json!({"element":"e","diagram":"d"}),
Mode::CompleteForEdit
),
json!({"element":"e","diagram":"d","port_anchors":[]})
);
assert_eq!(
rendered(
Context::Trace,
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
Mode::CompleteForEdit
),
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
);
for input in [
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
json!({"body":{"State":{"state":null,"lifeline":null}}}),
] {
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&input,
Mode::CompleteForEdit
),
input
);
}
for input in [
json!({"baseline":null}),
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
] {
assert_eq!(
rendered(Context::Trace, &input, Mode::CompleteForEdit),
input
);
}
}
#[test]
fn every_element_omits_only_empty_common_defaults() {
for kind in Kind::ALL {
// Future/unknown payloads are preservation sentinels, not admitted source.
compact(
Context::Element(kind),
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
);
compact(
Context::Element(kind),
json!({"description":" \n","parent":"owner"}),
json!({"description":" \n","parent":"owner"}),
);
}
}
#[test]
fn ordinary_element_defaults_are_context_specific() {
for kind in [Kind::Process, Kind::FunctionalChain] {
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
compact(
Context::Element(kind),
json!({"steps":["second","first"]}),
json!({"steps":["second","first"]}),
);
}
compact(
Context::Element(Kind::ExchangeItem),
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
json!({}),
);
compact(
Context::Element(Kind::ExchangeItem),
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
);
compact(
Context::Element(Kind::ExchangeCategory),
json!({"family":"Work","exchanges":[]}),
json!({}),
);
compact(
Context::Element(Kind::ExchangeCategory),
json!({"family":"Participant","exchanges":["x"]}),
json!({"family":"Participant","exchanges":["x"]}),
);
compact(
Context::Element(Kind::Interface),
json!({"supertype":null}),
json!({}),
);
compact(
Context::Element(Kind::Lifeline),
json!({"represented":null}),
json!({}),
);
compact(
Context::Element(Kind::Constraint),
json!({"expression":"","constrained":[]}),
json!({}),
);
}
#[test]
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
compact(
Context::Element(Kind::Scenario),
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
json!({}),
);
for viewpoint in [
"OperationalActivity",
"Functional",
"ParticipantInteraction",
"BoundaryInterface",
] {
compact(
Context::Element(Kind::Scenario),
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
);
}
}
#[test]
fn each_state_header_supplies_its_own_kind() {
for (kind, name) in [
(Kind::State, "State"),
(Kind::Mode, "Mode"),
(Kind::Initial, "Initial"),
(Kind::Final, "Final"),
(Kind::Choice, "Choice"),
(Kind::Junction, "Junction"),
(Kind::Fork, "Fork"),
(Kind::Join, "Join"),
(Kind::ShallowHistory, "ShallowHistory"),
(Kind::DeepHistory, "DeepHistory"),
(Kind::EntryPoint, "EntryPoint"),
(Kind::ExitPoint, "ExitPoint"),
(Kind::Terminate, "Terminate"),
] {
compact(
Context::Element(kind),
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
json!({}),
);
}
// Invalid mismatches must not be silently normalized by the writer policy.
compact(
Context::Element(Kind::Mode),
json!({"kind":"State","entry":"work"}),
json!({"kind":"State","entry":"work"}),
);
}
#[test]
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
compact(
Context::Element(Kind::DataField),
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
json!({}),
);
compact(
Context::Element(Kind::DataField),
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
);
for value in [
json!({"Boolean":false}),
json!({"Integer":0}),
json!({"Real":0.0}),
json!({"String":""}),
] {
compact(
Context::Element(Kind::DataField),
json!({"default":[value.clone()]}),
json!({"default":[value]}),
);
}
}
#[test]
fn data_definition_whole_default_and_selected_required_fields_differ() {
compact(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
json!({}),
);
for (input, expected) in [
(
json!({"Integer":{"minimum":null,"maximum":null}}),
json!({"Integer":{}}),
),
(
json!({"Real":{"minimum":null,"maximum":2.5}}),
json!({"Real":{"maximum":2.5}}),
),
(
json!({"String":{"minimum_length":4,"maximum_length":null}}),
json!({"String":{"minimum_length":4}}),
),
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
(
json!({"Enumeration":{"literals":[]}}),
json!({"Enumeration":{"literals":[]}}),
),
] {
compact(
Context::Element(Kind::DataType),
json!({"definition":input}),
json!({"definition":expected}),
);
}
compact(
Context::Element(Kind::DataType),
json!({"definition":"Boolean","default":{"Boolean":false}}),
json!({"definition":"Boolean","default":{"Boolean":false}}),
);
}
#[test]
fn port_contract_and_its_required_payload_stay_explicit() {
compact(
Context::Element(Kind::Port),
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
);
for contract in [
json!({"FunctionInput":{"incoming_items":[]}}),
json!({"FunctionOutput":{"outgoing_items":[]}}),
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
] {
compact(
Context::Element(Kind::Port),
json!({"contract":contract.clone(),"legacy":null}),
json!({"contract":contract}),
);
}
}
#[test]
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
json!({}),
);
for (input, expected) in [
(
json!({"Action":{"action":null,"lifeline":null}}),
json!({"Action":{}}),
),
(
json!({"State":{"state":null,"lifeline":null}}),
json!({"State":{}}),
),
(
json!({"SequenceReference":{"sequence":null}}),
json!({"SequenceReference":{}}),
),
(
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
json!({"ScenarioUse":{"bindings":[]}}),
),
(
json!({"Branch":{"guard":"","occurrences":[]}}),
json!({"Branch":{"guard":"","occurrences":[]}}),
),
(
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
),
(
json!({"Control":{"kind":"Choice"}}),
json!({"Control":{"kind":"Choice"}}),
),
] {
compact(
Context::Element(Kind::Occurrence),
json!({"body":input}),
json!({"body":expected}),
);
}
}
#[test]
fn unresolved_operation_and_required_message_value_are_preserved() {
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
);
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
);
}
#[test]
fn event_body_whole_default_does_not_prune_selected_required_fields() {
compact(
Context::Element(Kind::Event),
json!({"body":{"Signal":{"items":[]}}}),
json!({}),
);
for body in [
json!({"Signal":{"items":["i"]}}),
json!({"Time":{"kind":"After","expression":""}}),
json!({"Change":{"expression":""}}),
] {
compact(
Context::Element(Kind::Event),
json!({"body":body.clone()}),
json!({"body":body}),
);
}
}
#[test]
fn relation_required_endpoints_and_contextual_payloads_remain() {
for kind in RelationKind::ALL {
compact(
Context::Relation(kind),
json!({"description":"","source":"s","target":"t","future":[]}),
json!({"source":"s","target":"t","future":[]}),
);
}
compact(
Context::Relation(RelationKind::FunctionalExchange),
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
);
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
compact(
Context::Relation(RelationKind::ContextualLink),
contextual.clone(),
contextual,
);
}
#[test]
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
for (kind, orientation) in [
(RelationKind::BoundaryExchange, true),
(RelationKind::CommunicationMean, false),
] {
compact(
Context::Relation(kind),
json!({"oriented":orientation,"items":[]}),
json!({}),
);
compact(
Context::Relation(kind),
json!({"oriented":!orientation}),
json!({"oriented":!orientation}),
);
}
compact(
Context::Relation(RelationKind::DataAssociation),
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
json!({}),
);
compact(
Context::Relation(RelationKind::DataAssociation),
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
);
compact(
Context::Relation(RelationKind::InterfaceItemAllocation),
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
json!({}),
);
compact(
Context::Relation(RelationKind::CapabilityExtend),
json!({"condition":""}),
json!({}),
);
compact(
Context::Relation(RelationKind::SequenceLink),
json!({"guard":""}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateAction),
json!({"phase":"Do"}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateTransition),
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateTransition),
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
);
}
#[test]
fn diagram_options_remove_only_unrestricted_defaults() {
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
compact(
Context::Diagram,
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
json!({}),
);
compact(
Context::Diagram,
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
);
compact(
Context::Diagram,
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
json!({"routes":[{"edge":"e"}]}),
);
}
#[test]
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
compact(
Context::Node,
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
json!({"diagram":"d","element":"e"}),
);
for position in [
json!({"x":-0.0,"y":0.0}),
json!({"x":0.0,"y":-0.0}),
json!({"x":1.0,"y":0.0}),
] {
let result = rendered(
Context::Node,
&json!({"position":position.clone()}),
Mode::Compact,
);
assert_eq!(result, json!({"position":position.clone()}));
for coordinate in ["x", "y"] {
assert_eq!(
result["position"][coordinate].as_f64().unwrap().to_bits(),
position[coordinate].as_f64().unwrap().to_bits(),
"coordinate sign is part of fidelity"
);
}
}
compact(
Context::Node,
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
);
}
#[test]
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
compact(
Context::Artifact,
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
json!({"owner":"o"}),
);
compact(
Context::Artifact,
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
);
compact(
Context::Trace,
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
json!({"oa":"o","sa":"s"}),
);
compact(
Context::Trace,
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
);
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
compact(
Context::Trace,
json!({"kind":"Manual","baseline":baseline.clone()}),
json!({"kind":"Manual","baseline":baseline}),
);
}
#[test]
fn nested_future_fields_prevent_whole_default_removal() {
// These sentinels exercise preservation, not current parser admission of
// unknown fields. Only known slots may lose their redundant defaults.
compact(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
);
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
json!({"body":{"Exchange":{"future":false}}}),
);
compact(
Context::Diagram,
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
json!({"view_options":{"future":null}}),
);
}
#[test]
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
for definition in [
json!({"Future":{"minimum":null,"maximum":null}}),
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
] {
compact(
Context::Element(Kind::DataType),
json!({"definition":definition.clone()}),
json!({"definition":definition}),
);
}
for body in [
json!({"Future":{"operation":null,"values":[]}}),
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
] {
let fields = json!({"body":body});
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&fields,
Mode::CompleteForEdit
),
fields,
"do not invent a selected Exchange constructor in an unknown wrapper",
);
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
}
}
+6 -4
View File
@@ -40,6 +40,7 @@ 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 {
@@ -526,10 +527,11 @@ pub fn discover_missing(
result.expectations.keys().chain(result.bundle.files.keys()),
)?;
if result.diagnostics.is_empty() {
match compile_bundle(&result.reachable_bundle(), limits) {
Ok(compiled) => result.diagnostics = compiled.diagnostics,
Err(diagnostics) => result.diagnostics = diagnostics,
}
// 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);
}
let pending = journal_path(&result.root, &result.bundle.entry);
if fs::symlink_metadata(&pending).is_ok() {
+131 -20
View File
@@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
pub fn format_bundle(project: &Project) -> SourceBundle {
let ownership = default_ownership(project);
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut declarations = language::format_declarations(project);
let mut declarations =
language::format_declarations(project, language::DeclarationFormat::Compact);
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
for (id, declaration) in declarations {
@@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.or_default()
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
if !project.description.is_empty() {
entry.push_str(&format!(
" description {};\n",
quote(&project.description)
));
}
for path in files.keys() {
entry.push_str(&format!(" include {};\n", quote(path)));
}
@@ -850,8 +853,25 @@ pub fn update_bundle_with_identity_map(
)]);
}
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
// Comparison must include defaults even when persistence serialization or
// compact output omits them. Otherwise an unrelated payload edit erases
// unchanged explicit fields from the authored document.
let before: BTreeMap<_, _> =
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let after: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let compact: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::Compact)
.into_iter()
.collect();
let compact_values: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompactValues)
.into_iter()
.collect();
let old_aliases: BTreeMap<_, _> = old
.identities()
.into_iter()
@@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map(
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
let next_id = identities[&id];
let value_text = &compact_values[&next_id];
let insertion = InsertionSyntax::new(
&next.fields,
&parse(value_text)?.fields,
value_text,
&parse(&compact[&next_id])?.fields,
);
let old_kind = previous.header["kind"];
let new_kind = next.header["kind"];
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
{
// An implicit value is supplied by its declaration context.
// After a kind change, an unchanged value may need explicit
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
// Reconsider only absent fields; authored explicit values still
// compare against their complete old values and keep trivia.
previous_fields.retain(|key, _| actual_fields.contains_key(key));
}
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
@@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map(
source,
before: old_text,
after: new_text,
insertion: Some(&insertion),
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
@@ -953,6 +995,7 @@ pub fn update_bundle_with_identity_map(
source,
before: &before,
after: &after,
insertion: None,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
@@ -1011,7 +1054,7 @@ pub fn update_bundle_with_identity_map(
project,
identities,
&original_order,
&after,
&compact,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
@@ -1119,10 +1162,73 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
output
}
// Value fragments retain required payload fields even when the enclosing
// declaration field is itself omittable. For example, a selected String type
// needs minimum_length; an Event Signal needs items. The complete tree remains
// the comparison authority; this parallel tree is only an insertion template.
struct InsertionSyntax<'a> {
source: &'a str,
values: BTreeMap<usize, Span>,
omitted_fields: BTreeSet<usize>,
}
impl<'a> InsertionSyntax<'a> {
fn new(
complete: &language::EditValue,
compact_values: &language::EditValue,
source: &'a str,
compact: &language::EditValue,
) -> Self {
let mut result = Self {
source,
values: BTreeMap::new(),
omitted_fields: BTreeSet::new(),
};
result.collect(complete, compact_values);
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
(&complete.kind, &compact.kind)
{
for (key, field) in all {
if !kept.contains_key(key) {
result.omitted_fields.insert(field.value.span.start);
}
}
}
result
}
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
// Borrow the existing compact text once. Copying each containing record
// here would duplicate a large nested payload at every depth.
self.values.insert(complete.span.start, compact.span);
match (&complete.kind, &compact.kind) {
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
for (key, field) in all {
if let Some(next) = kept.get(key) {
self.collect(&field.value, &next.value);
} else {
self.omitted_fields.insert(field.value.span.start);
}
}
}
(language::EditKind::List(all), language::EditKind::List(kept)) => {
assert_eq!(
all.len(),
kept.len(),
"compaction preserves array membership"
);
for (field, next) in all.iter().zip(kept) {
self.collect(field, next);
}
}
_ => {}
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
after: &'a str,
insertion: Option<&'a InsertionSyntax<'a>>,
old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>,
// Source3 migration only adds path qualification; the existing field identity
@@ -1131,6 +1237,16 @@ struct SyntaxUpdate<'a> {
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn inserted(&self, value: &language::EditValue) -> &str {
self.insertion
.and_then(|syntax| {
syntax
.values
.get(&value.span.start)
.map(|span| &syntax.source[span.start..span.end])
})
.unwrap_or(&self.after[value.span.start..value.span.end])
}
fn header(
&self,
actual: &BTreeMap<&str, Span>,
@@ -1243,7 +1359,7 @@ impl SyntaxUpdate<'_> {
_ => {
erase_syntax(self.source, actual.span, edits);
let text = inserted_value(
&self.after[after.span.start..after.span.end],
self.inserted(after),
indent_at(self.source, actual.span.start),
self.source,
);
@@ -1308,6 +1424,9 @@ impl SyntaxUpdate<'_> {
.iter()
.filter(|(key, field)| {
!actual.contains_key(*key)
&& self.insertion.is_none_or(|syntax| {
!syntax.omitted_fields.contains(&field.value.span.start)
})
&& before
.and_then(|fields| fields.get(*key))
.is_none_or(|old| !self.same(&old.value, &field.value))
@@ -1326,11 +1445,7 @@ impl SyntaxUpdate<'_> {
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
let mut text = String::new();
for (key, field) in added {
let value = inserted_value(
&self.after[field.value.span.start..field.value.span.end],
&indent,
self.source,
);
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
if multiline {
text.push_str(&indent);
text.push_str(&format!("{key} {value};{}", newline(self.source)));
@@ -1450,11 +1565,7 @@ impl SyntaxUpdate<'_> {
} else {
text.push(' ');
}
text.push_str(&inserted_value(
&self.after[next.span.start..next.span.end],
&indent,
self.source,
));
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
suffix = "";
}
if i + 1 < after.len() {
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let canonical: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
@@ -59,6 +62,7 @@ pub(super) fn upgrade(
source,
before: source,
after,
insertion: None,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
+14
View File
@@ -643,6 +643,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
+385
View File
@@ -0,0 +1,385 @@
use oa_core::{projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
EdgeAttachment { side, offset }
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Report", None)
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.place(d, a, pos(50., 50.));
p.place(d, b, pos(750., 200.));
(p, d, a, b, edge)
}
fn stored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
}
}
fn routed(p: &Project, d: Id) -> RoutedView {
let diagram = &p.diagrams[&d];
route_diagram(p, diagram, &project_diagram(p, diagram))
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 0.002, "{a} != {b}");
}
fn orthogonal(points: &[Position]) {
assert!(points.len() >= 2);
for span in points.windows(2) {
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
}
}
fn length(points: &[Position]) -> f32 {
points
.windows(2)
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
.sum()
}
#[test]
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
let (mut p, d, a, _, edge) = fixture();
let initial = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
route.waypoints = vec![pos(460., 350.)];
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
let full = routed(&p, d);
assert_eq!(
preview, full,
"attachment-only changes must invalidate the label-only path cache"
);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
assert_eq!(
full.edges[&edge].points[0],
pos(
n.position.x + n.size.width * 0.25,
n.position.y + n.size.height
)
);
assert_eq!(full.edges[&edge].pins, route.waypoints);
orthogonal(&full.edges[&edge].points);
let node = p
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == a)
.unwrap();
node.position.x += 75.;
node.size.width += 100.;
node.size.height += 50.;
let moved = routed(&p, d);
near(
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
100.,
);
near(
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
50.,
);
assert_eq!(p.diagrams[&d].routes[0], route);
}
#[test]
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
let (mut p, d, _, _, edge) = fixture();
let baseline = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
assert_eq!(preview, routed(&p, d));
}
#[test]
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(0., 0.));
p.place(d, b, pos(600., 0.));
let diagram = p.diagrams[&d].clone();
let view = project_diagram(&p, &diagram);
let expected = route_diagram(&p, &diagram, &view);
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
assert_eq!(routed(&p, d), expected);
let mut hidden = view.clone();
hidden.ports.clear();
for e in &mut hidden.edges {
e.source_port = None;
e.target_port = None;
}
let absent = route_diagram(&p, &diagram, &hidden);
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
absent,
"hidden ports must not activate a dormant node preference"
);
}
fn state(kind: Kind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
p.place(d, a, pos(100., 100.));
p.place(d, b, pos(600., 100.));
(p, d, a, edge)
}
#[test]
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
for kind in [
Kind::Initial,
Kind::Junction,
Kind::Final,
Kind::Choice,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
Kind::Fork,
Kind::Join,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let diagram = &p.diagrams[&d];
let view = project_diagram(&p, diagram);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
for offset in [0., 0.2, 0.5, 0.8, 1.] {
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
let x = (at.x - n.position.x) / n.size.width;
let y = (at.y - n.position.y) / n.size.height;
match kind {
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
Kind::State => {
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
if offset == 0. || offset == 1. {
assert!(
x != 0. && x != 1. || y != 0. && y != 1.,
"rounded corner cannot use bounding-box corner"
);
}
}
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
}
}
}
}
}
#[test]
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
let (mut p, d, _, edge) = state(Kind::Choice);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
route.waypoints = vec![pos(380., 40.)];
route.label = Some(EdgeLabel {
fraction: 0.3,
offset: pos(0., 0.),
});
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let pin = r.pins[0];
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
near(
r.pin_fractions[0],
length(&r.points[..=pin_index]) / length(&r.points),
);
assert_eq!(
r.label.anchor,
point_along_polyline(&r.points, 0.3).unwrap()
);
}
#[test]
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
for kind in [
Kind::Initial,
Kind::Choice,
Kind::Terminate,
Kind::Fork,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for zoom in [0.5, 1., 2.] {
for overshoot in [0., 20.] {
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
let chosen =
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
chosen,
anchor(PortAnchorSide::Top, 0.5),
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
);
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
near(painted.x, n.position.x + n.size.width / 2.);
near(
painted.y,
n.position.y
+ if kind == Kind::Terminate {
n.size.height / 2.
} else {
0.
},
);
}
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
let stable =
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
stable.side,
PortAnchorSide::Right,
"corner hysteresis stays stable"
);
}
}
}
#[test]
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
for pins in [vec![], vec![pos(400., 30.)]] {
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
route.waypoints = pins.clone();
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert_eq!(r.points.first(), r.points.last());
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
assert_eq!(r.pins, pins);
assert_eq!(r.pin_fractions.len(), pins.len());
assert!(
r.warnings.contains(&RouteWarning::CoincidentAttachments),
"{:?}",
r.warnings
);
assert_eq!(routed(&p, d), result, "deterministic fallback");
assert_eq!(p.diagrams[&d].routes[0], route);
}
}
#[test]
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
for blocker_side in [-1., 1.] {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
let tip = attachment_point(
&p,
&p.diagrams[&d],
source,
&anchor(PortAnchorSide::Top, 0.5),
);
let region = p.elements[&a].parent;
let blocker = p
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
.unwrap();
let left = if blocker_side > 0. {
tip.x + 30.
} else {
tip.x - 30. - 224.
};
p.place(d, blocker, pos(left, tip.y - 144.));
let result = routed(&p, d);
let r = &result.edges[&edge];
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
orthogonal(&r.points);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
for segment in r.points.windows(2) {
for step in 0..=100 {
let t = step as f32 / 100.;
let q = pos(
segment[0].x + (segment[1].x - segment[0].x) * t,
segment[0].y + (segment[1].y - segment[0].y) * t,
);
assert!(
!(q.x > n.position.x - CLEARANCE
&& q.x < n.position.x + n.size.width + CLEARANCE
&& q.y > n.position.y - CLEARANCE
&& q.y < n.position.y + n.size.height + CLEARANCE),
"route crosses blocker: {q:?}"
);
}
}
assert!(
r.pins.is_empty(),
"generated detours must never become authored pins"
);
}
}
@@ -0,0 +1,272 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -0,0 +1,136 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
+154
View File
@@ -0,0 +1,154 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
@@ -0,0 +1,344 @@
//! Existing source-7 nested omission compatibility, independently of formatting.
//!
//! Selected constructors use their own field contracts. These handwritten
//! sources and literal models distinguish nullable optional slots from required
//! fields; they do not prescribe compact canonical output or broaden admission.
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
fn id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
}
fn bundle(declarations: &str) -> SourceBundle {
let source = format!(
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
);
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
}
}
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
Element {
id: id(number),
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body,
}
}
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
let expected = Project {
schema_version: 7,
id: id(1),
alias: "p".into(),
name: "Admission".into(),
description: String::new(),
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
};
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, expected, "complete literal model");
assert_eq!(
compiled.bundle, *input,
"fixed IDs require no source stabilization"
);
compiled.decoded.project
}
fn prefix(layer: Layer) -> &'static str {
match layer {
Layer::Oa => "oa",
Layer::Sa => "sa",
}
}
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
bundle(&format!(
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
prefix(layer)
))
}
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
assert_admitted(
&data_bundle(layer, source),
vec![element(
2,
"subject",
"Subject",
None,
ElementBody::shared_in(
layer,
SharedBody::DataType {
definition,
default: None,
unit: String::new(),
},
),
)],
)
}
fn assert_missing(input: &SourceBundle, field: &str) {
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
assert_eq!(
errors.len(),
1,
"expect the targeted admission error: {errors:?}"
);
let diagnostic = &errors[0].diagnostic;
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
assert!(
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
);
}
#[test]
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
for layer in [Layer::Oa, Layer::Sa] {
for (omitted, explicit, minimum, maximum) in [
(
"Integer {}",
"Integer { minimum none; maximum none; }",
None,
None,
),
(
"Integer { maximum 9; }",
"Integer { minimum none; maximum 9; }",
None,
Some(9),
),
(
"Integer { minimum -3; }",
"Integer { minimum -3; maximum none; }",
Some(-3),
None,
),
] {
let expected = DataTypeDefinition::Integer { minimum, maximum };
assert_eq!(
admit_data(layer, omitted, expected.clone()),
admit_data(layer, explicit, expected),
"omitted and explicit optional Integer bounds agree"
);
}
}
}
#[test]
fn real_optional_bounds_can_be_omitted_individually_and_together() {
for layer in [Layer::Oa, Layer::Sa] {
for (omitted, explicit, minimum, maximum) in [
(
"Real {}",
"Real { minimum none; maximum none; }",
None,
None,
),
(
"Real { maximum 2.5; }",
"Real { minimum none; maximum 2.5; }",
None,
Some(2.5),
),
(
"Real { minimum -1.25; }",
"Real { minimum -1.25; maximum none; }",
Some(-1.25),
None,
),
] {
let expected = DataTypeDefinition::Real { minimum, maximum };
assert_eq!(
admit_data(layer, omitted, expected.clone()),
admit_data(layer, explicit, expected),
"omitted and explicit optional Real bounds agree"
);
}
}
}
#[test]
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
for layer in [Layer::Oa, Layer::Sa] {
let expected = DataTypeDefinition::String {
minimum_length: 4,
maximum_length: None,
};
assert_eq!(
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
admit_data(
layer,
"String { minimum_length 4; maximum_length none; }",
expected,
),
);
}
}
#[test]
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
assert_missing(
&data_bundle(layer, "String { maximum_length none; }"),
"minimum_length",
);
admit_data(
layer,
"String { minimum_length 0; }",
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
);
}
}
#[test]
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
for layer in [Layer::Oa, Layer::Sa] {
let unbounded = admit_data(
layer,
"Integer { minimum -3; }",
DataTypeDefinition::Integer {
minimum: Some(-3),
maximum: None,
},
);
let bounded = admit_data(
layer,
"Integer { minimum -3; maximum 0; }",
DataTypeDefinition::Integer {
minimum: Some(-3),
maximum: Some(0),
},
);
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
}
}
#[test]
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
for layer in [Layer::Oa, Layer::Sa] {
let expected = DataTypeDefinition::Record { supertype: None };
assert_eq!(
admit_data(layer, "Record {}", expected.clone()),
admit_data(layer, "Record { supertype none; }", expected),
);
let errors = compile_bundle(
&data_bundle(layer, "Record { fields []; }"),
ProjectLimits::default(),
)
.unwrap_err();
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
assert!(
errors[0]
.diagnostic
.message
.contains("unknown field `fields`"),
"Record remains a closed constructor: {errors:?}"
);
}
}
#[test]
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
admit_data(
layer,
"Enumeration { literals []; }",
DataTypeDefinition::Enumeration { literals: vec![] },
);
}
}
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
let owner_kind = match layer {
Layer::Oa => "oa_entity",
Layer::Sa => "sa_system",
};
bundle(&format!(
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
prefix(layer)
))
}
fn admit_port(layer: Layer, fields: &str) -> Project {
let owner_body = match layer {
Layer::Oa => ElementBody::Oa(OaBody::Entity),
Layer::Sa => ElementBody::Sa(SaBody::System),
};
assert_admitted(
&port_bundle(layer, fields),
vec![
element(2, "owner", "Owner", None, owner_body),
element(
3,
"subject",
"Subject",
Some(id(2)),
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
}),
},
),
),
],
)
}
#[test]
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
for layer in [Layer::Oa, Layer::Sa] {
assert_eq!(
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
admit_port(
layer,
"contract Legacy; legacy { direction Out; interface none; };"
),
);
}
}
#[test]
fn legacy_port_direction_remains_required_inside_selected_record() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(
&port_bundle(layer, "contract Legacy; legacy {};"),
"direction",
);
assert_missing(
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
"direction",
);
}
}
#[test]
fn port_declaration_still_requires_explicit_contract() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(
&port_bundle(layer, "legacy { direction Out; };"),
"contract",
);
}
}
@@ -0,0 +1,445 @@
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
//! Legacy fixture migration uses the public parser. The original adapter tests
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
//! production formatters, not that frozen experiment or lossless model edits.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
const KEYED: &str =
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
const STATES: &str =
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
const DENSE: &str =
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
const OPERATIONS: &str = include_str!(
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
);
fn read(source: &str) -> Project {
language::parse(source).unwrap()
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
if project.description.is_empty() {
assert!(compiled.index.project_description.is_none());
}
let descriptions = project
.elements
.values()
.map(|e| (e.id, &e.description))
.chain(project.relations.values().map(|r| (r.id, &r.description)))
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
.chain(
project
.artifact_links
.values()
.map(|a| (a.id, &a.description)),
)
.chain(project.traces.values().map(|t| (t.id, &t.description)));
for (id, description) in descriptions {
if description.is_empty() {
assert!(
!compiled
.index
.fields
.contains_key(&(id, "description".into()))
);
}
}
for element in project.elements.values().filter(|e| e.parent.is_none()) {
assert!(
!compiled
.index
.fields
.contains_key(&(element.id, "parent".into()))
);
}
}
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
let flat = language::format(project);
let compact_model = read(&flat);
assert_eq!(
compact_model, *project,
"{label}: every persistent field survives"
);
let input = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
};
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.decoded.project, *project,
"{label}: source-project compile"
);
assert_eq!(
compiled.bundle, input,
"{label}: compact identities/source are stable"
);
assert_common_omissions(project, &compiled);
assert_eq!(
language::format(&compact_model),
flat,
"{label}: flat idempotence"
);
let modular = format_bundle(project);
assert!(
modular.files.len() > 1,
"{label}: exercise generated includes"
);
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
assert_eq!(
modular_compiled.decoded.project, *project,
"{label}: complete modular model"
);
assert_eq!(
modular_compiled.bundle, modular,
"{label}: modular source/IDs are stable"
);
assert_common_omissions(project, &modular_compiled);
assert_eq!(
format_bundle(&compact_model),
modular,
"{label}: flat-to-modular determinism"
);
assert_eq!(
format_bundle(&modular_compiled.decoded.project),
modular,
"{label}: modular idempotence"
);
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
// 2a64344, not a comparison against today's (now compact) canonical writer.
// A small header-layout allowance avoids enforcing incidental blank lines.
let measured_compact_bytes = match label {
"coastal-source3" => 28_465,
"keyed-source5-history" => 12_066,
"state-actions-source5" => 6_673,
"dense40-source6" => 19_342,
"boundary-operations-source6" => 3_660,
"state-negative-zero" => 6_672,
"keyed-present-empty-default" => 12_108,
"coastal-present-empty-artifact-strings" => 28_483,
"boundary-selected-and-unresolved-operations" => 3_806,
_ => panic!("missing independent compact measurement for {label}"),
};
assert!(
flat.len() <= measured_compact_bytes + 16,
"{label}: {} bytes exceeds compact evidence budget {}",
flat.len(),
measured_compact_bytes + 16
);
(compact_model, flat)
}
#[test]
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
assert!(COASTAL.starts_with("arcadia 3;"));
let project = read(COASTAL);
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
assert!(
project
.elements
.values()
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
);
assert!(project.elements.values().any(
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
));
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
&& d.projection == ProjectionKind::FunctionalDataflow
&& !d.nodes.is_empty()));
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
&& d.projection == ProjectionKind::Allocation
&& !d.nodes.is_empty()));
assert!(!project.artifact_links.is_empty());
assert!(project.traces.values().any(|t| t.baseline.is_some()));
assert_fidelity("coastal-source3", &project);
}
#[test]
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
let project = read(KEYED);
for layer in [Layer::Oa, Layer::Sa] {
assert!(project.elements.values().any(|e| e.layer() == layer
&& matches!(
e.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { .. },
..
})
)));
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
}
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
let facets: Vec<_> = project
.traces
.values()
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
.flat_map(|baseline| &baseline.facets)
.collect();
assert!(
facets
.iter()
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
);
assert!(
facets
.iter()
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
);
assert!(
KEYED.contains("uuid \""),
"fixture contains historical identity atoms"
);
let (_, source) = assert_fidelity("keyed-source5-history", &project);
assert!(
source.contains("uuid \""),
"historical atoms remain historical"
);
}
#[test]
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
let project = read(STATES);
let machine = identity(&project, "operating");
let view = &project.diagrams[&identity(&project, "state_view")];
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
assert!(!view.nodes.is_empty());
for phase in [
StateActionKind::Entry,
StateActionKind::Do,
StateActionKind::Exit,
] {
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
}
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
assert!(
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
);
assert_fidelity("state-actions-source5", &project);
}
#[test]
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
let project = read(DENSE);
assert_eq!(
project
.relations
.values()
.filter(|r| r.kind() == RelationKind::Interaction)
.count(),
40
);
assert_eq!(
project
.elements
.values()
.filter(|e| e.kind() == Kind::ExchangeCategory)
.count(),
5
);
assert_eq!(
project
.elements
.values()
.filter(|e| e.kind() == Kind::Process)
.count(),
3
);
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
assert_eq!(view.nodes.len(), 2);
assert!(view.routes.is_empty());
assert_eq!(
view.view_options.relation_kinds,
view_options::RelationKinds::All
);
assert_fidelity("dense40-source6", &project);
}
#[test]
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
let project = read(OPERATIONS);
assert_eq!(
project
.relations
.values()
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
.count(),
2
);
assert_eq!(
project
.elements
.values()
.filter(|e| matches!(
e.shared(),
Some(SharedBody::Port {
contract: PortContract::Component {
kind: ComponentPortKind::Standard,
..
},
..
})
))
.count(),
2
);
assert!(project.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
mechanism: ExchangeMechanism::Operation,
..
})
)));
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
assert_eq!(
project
.elements
.values()
.filter(|e| matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
operation: None,
..
},
..
})
))
.count(),
2
);
assert_fidelity("boundary-operations-source6", &project);
}
#[test]
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
let mut project = read(STATES);
let diagram = identity(&project, "state_view");
let node = identity(&project, "view_machine");
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position = Position { x: -0.0, y: 0.0 };
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat)]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
// Project/Position PartialEq alone equates the signs of zero. Check bits in
// each independently decoded representation, including source-project compile.
for actual in [
&decoded,
&compiled.decoded.project,
&modular.decoded.project,
] {
let position = &actual.diagrams[&diagram].nodes[&node].position;
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
}
}
#[test]
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
let mut project = read(KEYED);
let field_id = identity(&project, "oa_navigation_latitude");
let Some(SharedBody::DataField {
minimum,
maximum,
default,
storage,
..
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
else {
panic!("fixture must contain the claimed DataField");
};
assert_eq!(*minimum, 0);
assert_eq!(*maximum, Some(1));
assert_eq!(*default, None);
assert_eq!(*storage, FieldStorage::Value);
*maximum = None;
*default = Some(vec![]);
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
assert!(
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
);
}
#[test]
fn present_empty_artifact_revision_and_fragment_survive() {
let mut project = read(COASTAL);
let artifact_id = identity(&project, "console_enclosure_cad");
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
artifact.revision = Some(String::new());
artifact.fragment = Some(String::new());
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
assert_eq!(
decoded.artifact_links[&artifact_id].revision.as_deref(),
Some("")
);
assert_eq!(
decoded.artifact_links[&artifact_id].fragment.as_deref(),
Some("")
);
}
#[test]
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
let mut project = read(OPERATIONS);
let allocation = identity(&project, "operation_probe_30");
assert_eq!(
project.relations[&allocation].kind(),
RelationKind::InterfaceItemAllocation
);
let selected = identity(&project, "operation_probe_43");
let unresolved = identity(&project, "operation_probe_44");
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
else {
panic!("fixture must contain a Call Exchange");
};
assert_eq!(*operation, None);
*operation = Some(OperationUse { allocation: value });
}
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
assert!(
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
);
assert!(matches!(
decoded.elements[&unresolved].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(OperationUse { allocation: None }),
..
},
..
})
));
}
@@ -0,0 +1,256 @@
//! Focused public-output contracts. Field locations come from the real compiler;
//! token comparisons ignore layout while retaining typed tags and required syntax.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn project(declarations: &str) -> Project {
language::parse(&format!(
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
)).unwrap()
}
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
let flat = language::format(project);
assert_eq!(language::parse(&flat).unwrap(), *project);
let flat_bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat)]),
};
[flat_bundle, format_bundle(project)]
.into_iter()
.map(|bundle| {
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, *project);
assert_eq!(
compiled.bundle, bundle,
"all identities are explicit and stable"
);
compiled
})
.collect()
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
let location = compiled
.index
.fields
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
Some(
language::tokenize(text)
.into_iter()
.filter(|token| {
!matches!(
token.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|token| &text[token.span.start..token.span.end])
.collect::<Vec<_>>()
.join(" "),
)
}
#[test]
fn empty_project_has_no_synthetic_description_or_module() {
let empty = project("");
for compiled in exports(&empty) {
assert!(compiled.index.project_description.is_none());
assert!(compiled.index.fields.is_empty());
assert!(compiled.index.includes.is_empty());
assert_eq!(compiled.bundle.files.len(), 1);
assert_eq!(compiled.index.declarations.len(), 1);
}
}
#[test]
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
let mut model =
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
let work = identity(&model, "work");
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
model.description = description.into();
model.elements.get_mut(&work).unwrap().description = description.into();
for compiled in exports(&model) {
assert_eq!(
compiled.index.project_description.is_some(),
!description.is_empty()
);
assert_eq!(
field(&compiled, "work", "description").is_some(),
!description.is_empty()
);
assert!(field(&compiled, "work", "parent").is_none());
// exports compares the complete model, including exact whitespace
// and escapes in both project and element descriptions.
}
}
}
#[test]
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
let model = project(
r#"
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
node placement id "00000000-0000-4000-8000-000000000004" {
diagram @view; element @work;
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
collapsed false; port_anchors [];
}
"#,
);
for compiled in exports(&model) {
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
for name in ["description", "projection", "routes", "view_options"] {
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
}
assert_eq!(
field(&compiled, "placement", "diagram").as_deref(),
Some("@ view")
);
assert_eq!(
field(&compiled, "placement", "element").as_deref(),
Some("@ work")
);
for name in ["position", "size", "collapsed", "port_anchors"] {
assert!(
field(&compiled, "placement", name).is_none(),
"placement.{name}"
);
}
}
}
#[test]
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
let model = project(
r#"
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
definition { Integer { minimum none; maximum none; }; };
}
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
definition { String { minimum_length 4; maximum_length none; }; };
}
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
definition { Record { supertype none; }; };
}
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
definition Boolean; default { Boolean false; };
}
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
parent @record; minimum 0; maximum none; default [];
}
"#,
);
for compiled in exports(&model) {
assert!(field(&compiled, "implicit", "definition").is_none());
assert_eq!(
field(&compiled, "integer", "definition").as_deref(),
Some("{ Integer { } ; }")
);
assert_eq!(
field(&compiled, "text", "definition").as_deref(),
Some("{ String { minimum_length 4 ; } ; }")
);
assert_eq!(
field(&compiled, "record", "definition").as_deref(),
Some("{ Record { } ; }")
);
assert_eq!(
field(&compiled, "flag", "definition").as_deref(),
Some("Boolean")
);
assert_eq!(
field(&compiled, "flag", "default").as_deref(),
Some("{ Boolean false ; }")
);
assert_eq!(
field(&compiled, "optional", "minimum").as_deref(),
Some("0")
);
assert_eq!(
field(&compiled, "optional", "maximum").as_deref(),
Some("none")
);
assert_eq!(
field(&compiled, "optional", "default").as_deref(),
Some("[ ]")
);
assert!(field(&compiled, "optional", "type_ref").is_none());
}
}
#[test]
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
let model = project(
r#"
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
parent @work; contract { FunctionInput { incoming_items []; }; };
}
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
parent @host; contract Legacy; legacy { direction Out; interface none; };
}
"#,
);
for compiled in exports(&model) {
assert_eq!(
field(&compiled, "sink", "contract").as_deref(),
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
);
assert!(field(&compiled, "sink", "legacy").is_none());
assert_eq!(
field(&compiled, "preserved", "contract").as_deref(),
Some("Legacy")
);
assert_eq!(
field(&compiled, "preserved", "legacy").as_deref(),
Some("{ direction Out ; }")
);
}
}
#[test]
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
let model = project(
r#"
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
parent @flow; body { Exchange {}; };
}
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
parent @flow; body { Action { action none; lifeline none; }; };
}
"#,
);
for compiled in exports(&model) {
assert!(field(&compiled, "mixed", "viewpoint").is_none());
assert_eq!(
field(&compiled, "strict", "viewpoint").as_deref(),
Some("Functional")
);
assert!(field(&compiled, "exchange", "body").is_none());
assert_eq!(
field(&compiled, "action", "body").as_deref(),
Some("{ Action { } ; }")
);
assert_eq!(
field(&compiled, "action", "parent").as_deref(),
Some("@ flow")
);
}
}
@@ -0,0 +1,612 @@
//! Exact-source regressions for fields skipped by persistence serialization.
//!
//! The archived Windows baseline confirmed three exact-source failures after
//! full model equality passed. Complete comparison now preserves these fields;
//! the former ignored witnesses are normal regression tests.
//! No-op and same-record header controls remain normal tests: they must preserve
//! the same fixtures without entering the changed-payload record-erasure branch.
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
source @first; target @second;
}
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
steps [@subject];
}
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
parent @owner;
body { Exchange {
exchange none;
sender none; receiver none;
message_kind Asynchronous; reply_to none; values [];
operation none ; // Keep this authored default and its spelling.
}; };
}
}
"#;
const NODE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
node placement id "00000000-0000-4000-8000-000000000004" {
diagram @view; element @work;
position { x 12.5; y -4.0; };
port_anchors [] ; // Keep explicit automatic placement.
}
}
"#;
const TRACE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
oa @work; sa @function; kind Manual;
baseline {
oa { name "Work"; description "Old OA snapshot"; };
sa { name "Function"; description "Old SA snapshot"; };
semantic none ; // Keep the explicit prose-only baseline.
};
}
}
"#;
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
steps [@subject];
}
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
parent @owner;
body { Action {
action none;
lifeline none ; // Keep this shared field's distinctive spelling.
}; };
}
}
"#;
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
// These port declarations are untouched by the relation context change.
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
parent @first; contract Legacy;
legacy { direction Out; interface none; };
}
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
parent @second; contract Legacy;
legacy { direction In; interface none; };
}
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
source @output; target @input;
oriented true ; // Retain this rationale when changing the default value.
items [];
}
}
"#;
fn id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
}
fn source_bundle(source: &str) -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
}
}
fn project() -> Project {
Project {
schema_version: 7,
id: id(1),
alias: "p".into(),
name: "Preservation".into(),
description: String::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
}
}
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
Element {
id: id(number),
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body,
}
}
// Handwritten typed expectations are independent of canonical serialization and
// source hydration. No model factories or generated/random identities are used.
fn exchange_project() -> Project {
let mut expected = project();
expected.elements = [
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
element(
3,
"second",
"Second",
None,
ElementBody::Oa(OaBody::Activity),
),
element(
5,
"owner",
"Owner",
None,
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
),
element(
6,
"subject",
"Subject",
Some(id(5)),
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.relations.insert(
id(4),
Relation {
id: id(4),
alias: "flow".into(),
name: "Flow".into(),
description: String::new(),
body: RelationBody::Interaction {
source: id(2),
target: id(3),
items: vec![],
},
},
);
expected
}
fn node_project() -> Project {
let mut expected = project();
expected.elements.insert(
id(2),
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
);
expected.diagrams.insert(
id(3),
Diagram {
id: id(3),
alias: "view".into(),
name: "View".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Overview,
view_options: view_options::ViewOptions {
hidden_edges: vec![],
relation_kinds: view_options::RelationKinds::All,
work_categories: view_options::CategorySelection::All,
participant_categories: view_options::CategorySelection::All,
coverage: view_options::Coverage::All,
work_contexts: view_options::ContextSelection::All,
exchange_labels: view_options::ExchangeLabels::Name,
},
nodes: BTreeMap::from([(
id(4),
NodePlacement {
id: id(4),
alias: "placement".into(),
element: id(2),
position: Position { x: 12.5, y: -4.0 },
size: Size {
width: 224.0,
height: 94.0,
},
collapsed: false,
port_anchors: vec![],
},
)]),
routes: vec![],
},
);
expected
}
fn trace_project() -> Project {
let mut expected = project();
expected.elements = [
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
element(
3,
"function",
"Function",
None,
ElementBody::Sa(SaBody::Function),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.traces.insert(
id(4),
Trace {
id: id(4),
alias: "mapping".into(),
name: "Mapping".into(),
description: String::new(),
oa: id(2),
sa: id(3),
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: TracedProperties {
name: "Work".into(),
description: "Old OA snapshot".into(),
},
sa: TracedProperties {
name: "Function".into(),
description: "Old SA snapshot".into(),
},
semantic: None,
}),
},
);
expected
}
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
let input = source_bundle(source);
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.decoded.project, *expected,
"fixture model admission"
);
assert_eq!(
compiled.bundle, input,
"fixed IDs must avoid stabilization edits"
);
compiled
}
fn replacement(source: &str, before: &str, after: &str) -> String {
assert_eq!(
source.matches(before).count(),
1,
"unique authored edit token"
);
source.replacen(before, after, 1)
}
fn assert_preserved_update(
original: &CompiledSourceProject,
expected: &Project,
expected_source: &str,
) {
// Independently admit the exact expected spelling before testing the updater.
let expected_bundle = source_bundle(expected_source);
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
assert_eq!(
authored.decoded.project, *expected,
"expected source/model agree"
);
assert_eq!(
authored.bundle, expected_bundle,
"expected source is already stable"
);
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
assert_eq!(
updated.decoded.project, *expected,
"entire intended model survives"
);
assert_eq!(
updated.bundle, expected_bundle,
"only the requested token may change; preserve explicit defaults and all trivia"
);
}
// Context changes may introduce/remove redundant default syntax. Admit each
// narrowly enumerated spelling against the same literal target model, and then
// compare complete source bundles. No normalization may mask edits to unrelated
// fields, declarations, whitespace, or comments.
fn assert_context_update(
original: &CompiledSourceProject,
expected: &Project,
allowed_sources: &[String],
) {
let allowed: Vec<_> = allowed_sources
.iter()
.map(|source| {
let bundle = source_bundle(source);
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(
authored.decoded.project, *expected,
"allowed source/model agree"
);
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
bundle
})
.collect();
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
assert_eq!(
updated.decoded.project, *expected,
"entire context-change model survives"
);
assert!(
allowed.contains(&updated.bundle),
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
updated.bundle,
allowed
);
}
#[test]
fn explicit_exchange_operation_none_survives_noop() {
let expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
}
#[test]
fn explicit_exchange_operation_none_survives_header_rename() {
let mut expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
let mut expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
else {
panic!("literal fixture must be an Exchange occurrence");
};
*exchange = Some(id(4));
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_node_empty_port_anchors_survive_noop() {
let expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
assert_preserved_update(&original, &expected, NODE_SOURCE);
}
#[test]
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
let mut expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
// Nodes have no display-name header. Rename the same node's header alias.
expected
.diagrams
.get_mut(&id(3))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.alias = "renamed_placement".into();
let source = replacement(
NODE_SOURCE,
"node placement id",
"node renamed_placement id",
);
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_node_empty_port_anchors_survive_position_edit() {
let mut expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
expected
.diagrams
.get_mut(&id(3))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.position
.x = 25.0;
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_trace_semantic_none_survives_noop() {
let expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
assert_preserved_update(&original, &expected, TRACE_SOURCE);
}
#[test]
fn explicit_trace_semantic_none_survives_header_rename() {
let mut expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
let mut expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
expected
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.oa
.description = "Revised OA snapshot".into();
let source = replacement(
TRACE_SOURCE,
"\"Old OA snapshot\"",
"\"Revised OA snapshot\"",
);
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
let mut expected = project();
expected.elements = [
element(
2,
"owner",
"Owner",
None,
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
context: None,
precondition: String::new(),
postcondition: String::new(),
steps: vec![id(3)],
})),
),
element(
3,
"subject",
"Subject",
Some(id(2)),
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
expected.elements.get_mut(&id(3)).unwrap().body =
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::State {
state: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
}));
// Remove only the inapplicable field's syntax: its separating space and all
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
let compact = replacement(&source, "action none;", " ");
let expanded = replacement(
&compact,
" }; };",
" state none;\n }; };",
);
// Omission of newly applicable state and explicit state none both mean the
// literal State target; neither may retain the old action field.
assert_context_update(&original, &expected, &[compact, expanded]);
}
#[test]
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
let mut expected = project();
expected.elements = [
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
element(
5,
"output",
"Output",
Some(id(2)),
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
}),
})),
),
element(
6,
"input",
"Input",
Some(id(3)),
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::In,
interface: None,
}),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.relations.insert(
id(4),
Relation {
id: id(4),
alias: "link".into(),
name: "Link".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source: id(5),
target: id(6),
oriented: true,
items: vec![],
},
},
);
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
// BoundaryExchange connects participant ports; CommunicationMean connects
// their OA entity owners. Endpoint changes are explicit parts of this target,
// not inferred or normalized away. Port records remain exactly unchanged.
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
source: id(2),
target: id(3),
oriented: false,
items: vec![],
};
let source = replacement(
RELATION_CONTEXT_SOURCE,
"boundary_exchange link",
"communication_mean link",
);
let source = replacement(&source, "source @output;", "source @first;");
let source = replacement(&source, "target @input;", "target @second;");
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
// Three spaces between name/value plus one before the semicolon survive
// syntax removal. The following rationale comment and all other bytes stay.
let omitted = replacement(&source, "oriented true ;", " ");
assert_context_update(&original, &expected, &[explicit, omitted]);
}
@@ -0,0 +1,222 @@
//! Public update witnesses for compact insertion and complete comparison.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn compile(body: &str) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
),
("model.arcadia".into(), body.into()),
]),
},
ProjectLimits::default(),
)
.unwrap()
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
assert_eq!(result.decoded.project, *project);
assert_eq!(
result.bundle.files["project.arcadia"],
before.bundle.files["project.arcadia"]
);
assert_eq!(
compile_bundle(&result.bundle, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
assert_eq!(
update_bundle(&result, project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
result
}
fn words(text: &str) -> Vec<&str> {
language::tokenize(text)
.into_iter()
.filter(|t| {
!matches!(
t.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|t| &text[t.span.start..t.span.end])
.collect()
}
#[test]
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
let before = compile(
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
);
let mut project = before.decoded.project.clone();
let occurrence = id(&project, "o");
let Some(SharedBody::Occurrence { body, .. }) =
project.elements.get_mut(&occurrence).unwrap().shared_mut()
else {
panic!("occurrence")
};
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
assert!(tokens.contains(&"Action"));
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
assert!(!tokens.contains(&absent), "{absent}: {text}");
}
assert!(text.contains("parent @s; // keep this"));
}
#[test]
fn default_signal_constructor_still_inserts_its_required_empty_items() {
let before =
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
let mut project = before.decoded.project.clone();
let event = id(&project, "e");
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
else {
panic!("event")
};
*body = EventBody::Signal { items: vec![] };
let result = update(&before, &project);
let tokens = words(&result.bundle.files["model.arcadia"]);
assert!(tokens.contains(&"Signal"));
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
assert!(!tokens.contains(&"expression"));
assert!(!tokens.contains(&"kind"));
}
#[test]
fn scalar_to_default_string_record_retains_required_minimum_only() {
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
let mut project = before.decoded.project.clone();
let datatype = id(&project, "t");
let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&datatype).unwrap().shared_mut()
else {
panic!("data type")
};
*definition = DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.contains(&"String"));
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
assert!(!tokens.contains(&"maximum_length"));
assert!(text.contains("// typed rationale"));
}
#[test]
fn inserted_route_list_item_omits_only_optional_fields() {
let before = compile(
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
);
let mut project = before.decoded.project.clone();
let diagram = id(&project, "d");
let edge = id(&project, "flow");
// Deserialize only the explicit witness value, independently of omission policy.
project.diagrams.get_mut(&diagram).unwrap().routes =
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
.unwrap();
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
assert!(!tokens.contains(&"waypoints"));
assert!(!tokens.contains(&"label"));
assert!(text.contains("// routing rationale"));
}
#[test]
fn zero_sign_change_inserts_position_without_default_siblings() {
let before = compile(
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
);
let mut project = before.decoded.project.clone();
let diagram = id(&project, "d");
let node = id(&project, "n");
assert_eq!(
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
0.0_f32.to_bits()
);
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position
.x = -0.0;
let result = update(&before, &project);
assert_eq!(
result.decoded.project.diagrams[&diagram].nodes[&node]
.position
.x
.to_bits(),
(-0.0_f32).to_bits()
);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
for absent in ["size", "collapsed", "port_anchors"] {
assert!(!tokens.contains(&absent));
}
assert!(text.contains("// placement rationale"));
}
#[test]
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
let before = compile(
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
);
let mut project = before.decoded.project.clone();
let flow = id(&project, "flow");
assert!(matches!(
project.relations[&flow].body,
RelationBody::BoundaryExchange { oriented: true, .. }
));
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
source: id(&before.decoded.project, "a"),
target: id(&before.decoded.project, "b"),
oriented: true,
items: vec![],
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
assert!(
words(text)
.windows(3)
.any(|w| w == ["oriented", "true", ";"])
);
assert!(text.contains("// orientation inherited"));
let before_ports = &before.bundle.files["model.arcadia"];
assert_eq!(
text.split("communication_mean").next().unwrap(),
before_ports.split("boundary_exchange").next().unwrap()
);
}
+290
View File
@@ -0,0 +1,290 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
+34 -13
View File
@@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *};
fn read(source: &str) -> Project {
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
}
fn replace_once(source: &str, before: &str, after: &str) -> String {
assert_eq!(
source.matches(before).count(),
1,
"unique mutation anchor {before:?}"
);
let changed = source.replacen(before, after, 1);
assert_ne!(changed, source, "test mutation must change the source");
changed
}
fn rejected(source: &str, fragment: &str) {
let errors = language::parse(source).expect_err(source);
assert!(
@@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
source.push('}');
let model = read(&source);
let canonical = language::format(&model);
assert!(!canonical.contains("transition_kind External;"));
for fragment in [
"transition_kind External;",
"transition_kind Local;",
"transition_kind Internal;",
"triggers [@deadline, @assess];",
@@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
.find(|r| r.alias == "recover")
.unwrap();
let RelationBody::StateTransition {
transition_kind,
trigger,
guard,
effect,
@@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
else {
panic!("expected transition")
};
assert_eq!(*transition_kind, TransitionKind::External);
assert_eq!(trigger, "Operator confirms\nНе запускать код");
assert_eq!(guard, "available && status == \"Ready\"");
assert_eq!(effect, "Record the assessment");
// External is omitted in compact output. Insert an explicit invalid
// discriminator into Recover using its unique, nondefault trigger list.
let invalid_kind = replace_once(
&canonical,
"triggers [@deadline, @assess];",
"transition_kind Unsupported;\n triggers [@deadline, @assess];",
);
rejected(&invalid_kind, "Unsupported");
for invalid in [
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
canonical.replace(
replace_once(
&canonical,
"triggers [@deadline, @assess];",
"triggers [\"deadline\", @assess];",
),
canonical.replace(
replace_once(
&canonical,
"triggers [@deadline, @assess];",
"triggers [@deadline, @deadline];",
),
canonical.replace("effects [@assess];", "effects [@payload];"),
canonical.replace("kind After;", "kind Relative;"),
canonical.replace("items [@payload];", "items [@assess];"),
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
replace_once(&canonical, "kind After;", "kind Relative;"),
replace_once(&canonical, "items [@payload];", "items [@assess];"),
] {
assert!(
language::parse(&invalid).is_err(),
"invalid typed state/event source accepted"
);
}
let legacy = canonical
.replace("transition_kind External;", "")
.replace("triggers [@deadline, @assess];", "")
.replace("effects [@assess];", "");
// Canonical source already omits External. Removing the supplied lists
// independently exercises their empty omission defaults and retained prose.
let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", "");
let legacy = replace_once(&legacy, "effects [@assess];", "");
let legacy = read(&legacy);
let RelationBody::StateTransition {
transition_kind,
@@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
(retained_trigger, retained_guard, retained_effect),
(trigger, guard, effect)
);
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
assert!(language::parse(&draft).is_ok());
let deadline = model
.elements
@@ -1038,7 +1059,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
"fixture must cover actual old scenario content"
);
let canonical = language::format(&from_source);
assert!(canonical.contains("viewpoint MixedLegacy;"));
assert!(!canonical.contains("viewpoint MixedLegacy;"));
for relation in from_source.relations.values() {
if let RelationBody::StateTransition {
transition_kind,
+11 -1
View File
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
@@ -767,7 +769,15 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec
assert_eq!(cleared.decoded.project, prepared.project);
let text = &cleared.bundle.files["project.arcadia"];
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
assert!(!text.contains("port_anchors"));
// Lossless editing may retain the authored empty field and its annotations.
// Canonical export, rather than a model update, normalizes default syntax.
assert!(!language::format(&cleared.decoded.project).contains("port_anchors"));
assert_eq!(
update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle
);
let prepared = prepare_command(
&cleared.decoded.project,
&Command::SetPortAnchor {
+4
View File
@@ -70,6 +70,8 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -689,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
+2
View File
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
+2
View File
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
+84
View File
@@ -383,3 +383,87 @@ 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);
}
+16
View File
@@ -301,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -362,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -422,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -456,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -583,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -652,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -730,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
@@ -748,6 +748,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![xy(420., 400.), xy(420., 550.)],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -70,6 +70,7 @@ impl Root<'_> {
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
+477 -47
View File
@@ -20,6 +20,7 @@ mod reference_authoring;
mod review_navigation_tests;
pub(crate) mod source_rename;
mod source_symbols;
pub(crate) mod text_workspace;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
@@ -440,8 +441,7 @@ pub enum Message {
IncludesLoaded(u64, Result<DiskProject, String>),
Lint(u64),
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
TextWorkspace(text_workspace::Event),
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
@@ -493,6 +493,7 @@ pub struct App {
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
pub exact_strings: bool,
pub document: Document,
pub page: Page,
@@ -537,6 +538,7 @@ pub struct App {
pub source_navigation: crate::source_navigation::State,
source_find_instance: Id,
pub source_symbols: crate::source_navigation::SymbolState,
pub text_workspace: text_workspace::State,
pub source_rename: source_rename::State,
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
@@ -612,6 +614,7 @@ impl App {
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
selected_attachment: None,
exact_strings: false,
document,
page: Page::Diagram,
@@ -659,6 +662,7 @@ impl App {
source_navigation: crate::source_navigation::State::default(),
source_find_instance: Id::new_v4(),
source_symbols: crate::source_navigation::SymbolState::default(),
text_workspace: text_workspace::State::default(),
source_rename: source_rename::State::default(),
diagram_usages: None,
diagram_navigation_focus: None,
@@ -713,10 +717,35 @@ impl App {
fn sync_source(&mut self) {
self.sync_source_with_alias_editor(None);
}
fn publish_source_diagnostics(&mut self, diagnostics: Vec<SourceDiagnostic>) {
self.source_diagnostics = diagnostics;
self.text_workspace.publish_diagnostics(self.source_key());
}
/// A different source destination owns a new navigation/presentation session.
/// Only diagnostics proven current before this identity-only change may be
/// republished for it; stale source locations must not become fresh again.
fn reset_source_navigation_session(&mut self) {
let diagnostics_current = self
.text_workspace
.diagnostic_publication()
.is_some_and(|publication| publication.source() == self.source_key());
self.source_workspace.reset_navigation_session();
self.clear_source_presentation();
if diagnostics_current {
self.publish_source_diagnostics(self.source_diagnostics.clone());
}
}
fn clear_source_presentation(&mut self) {
let in_flight = self.source_symbols.in_flight;
self.source_symbols = crate::source_navigation::SymbolState::default();
self.source_symbols.in_flight = in_flight;
self.source_jump = None;
self.text_workspace.reset();
}
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
self.source_workspace.accept(self.document.bundle());
self.show_source_file_with_alias_editor(false, hint);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
self.source_version += 1;
self.lint_requested = None;
}
@@ -857,7 +886,7 @@ impl App {
}
}
Err(issues) => {
self.source_diagnostics = issues;
self.publish_source_diagnostics(issues);
self.status = "Source contains errors. Your draft is preserved.".into();
self.page = Page::Text;
false
@@ -1013,6 +1042,7 @@ impl App {
let navigation_in_flight = self.source_symbols.in_flight;
let rename_in_flight = self.source_rename.in_flight;
let diagram_lookup_in_flight = self.diagram_lookup_in_flight;
let menu_sequence = self.menu_sequence;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
@@ -1027,6 +1057,7 @@ impl App {
self.source_symbols.in_flight = navigation_in_flight;
self.source_rename.in_flight = rename_in_flight;
self.diagram_lookup_in_flight = diagram_lookup_in_flight;
self.menu_sequence = menu_sequence;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1074,6 +1105,9 @@ impl App {
previous != (self.layer, self.active_diagram)
}
fn select(&mut self, id: Id) {
if id != self.selected {
self.selected_attachment = None;
}
if self.selected == id {
return;
}
@@ -1124,7 +1158,7 @@ impl App {
pub fn view(&self) -> Element<'_, Message> {
let base = crate::context_menu::Popup::new(
crate::views::view(self),
crate::model_menu::popup(self),
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
)
.into();
crate::source_navigation::interaction::observe(
@@ -1192,14 +1226,15 @@ impl App {
// Use one captured-Escape bridge: a focused Find input consumes
// Escape, while modal priority remains in the ordinary handler.
behavior::creation::subscription(
self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.source_symbols.open
|| self.source_navigation.open
|| self.source_rename.open())
&& self.architecture.is_none()),
self.text_workspace.menu().is_none()
&& (self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.text_workspace.drawer().is_some()
|| self.source_navigation.open
|| self.source_rename.open())
&& self.architecture.is_none())),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
@@ -1249,6 +1284,11 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Captured Text commands validate their origin before they can
// invalidate that same origin or pass into existing command handlers.
if let Message::TextWorkspace(event) = message {
return self.text_workspace_event(event);
}
// These handlers reject old owners/tokens without changing state. Apply
// the same admission before shared camera and allocation boundaries.
if !self.source_input_envelope(&message)
@@ -1411,6 +1451,7 @@ impl App {
}
message => message,
};
// Internal results and geometry do not constitute a new user intent.
// Process query completion even behind a modal to release its worker slot;
// its handler independently refuses navigation outside the live editor.
@@ -1423,6 +1464,38 @@ impl App {
if let Message::DiagramUsagesReady(completion) = message {
return self.diagram_usages_ready(completion);
}
if self.text_workspace.menu().is_some() {
if matches!(
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
self.text_workspace.dismiss_menu();
self.source_interaction();
return Task::none();
}
// Global subscriptions can receive captured keyboard input.
// A Text menu owns those commands while passive work can finish.
if !matches!(
message,
Message::CheckpointWritten(..)
| Message::CheckpointLoaded(..)
| Message::CheckpointTick
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
| Message::Lint(_)
| Message::Linted(..)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(..)
| Message::SavePath(_)
| Message::SourceRecovered(_)
| Message::Exported(_)
) {
return Task::none();
}
}
if !matches!(
&message,
Message::CheckpointTick
@@ -2190,11 +2263,11 @@ impl App {
// Validate the originating canvas before advancing its input
// epoch. Actions are then generated with the refreshed context,
// while pointer history hidden behind the menu becomes stale.
let Some(opened) = crate::model_menu::Opened::new(
self,
request,
self.menu_sequence.wrapping_add(1),
) else {
let Some(token) = self.menu_sequence.checked_add(1) else {
self.status = "Reopen the application to start a new menu session.".into();
return Task::none();
};
let Some(opened) = crate::model_menu::Opened::new(self, request, token) else {
return Task::none();
};
self.connection = None;
@@ -2248,7 +2321,8 @@ impl App {
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.source_symbols.open);
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
@@ -2264,15 +2338,11 @@ impl App {
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
}
if restore_source_focus && self.source_navigation.open {
// Dismiss the active Find surface first. Its underlying
// references remain available until a separate Escape.
self.source_navigation.open = false;
} else {
self.source_navigation.open = false;
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
self.source_navigation.open = false;
// Popup/modal priority was handled above. In the available Text
// workspace, one Escape dismisses Find before the drawer beneath it.
if !dismiss_find_only {
self.text_workspace.show_drawer(None);
}
if restore_source_focus {
self.restore_source_editor_focus();
@@ -3260,7 +3330,7 @@ impl App {
.edit(after.clone(), self.source_editor.cursor());
self.source_snapshot = Arc::from(after);
self.source_version += 1;
self.source_diagnostics.clear();
self.publish_source_diagnostics(Vec::new());
let version = self.source_version;
return Task::perform(
async move {
@@ -3325,8 +3395,17 @@ impl App {
}
self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again.");
self.pending_recovery = None;
// Removing the recovered-save notice changes presentation,
// not the source authority of the remaining locations.
let diagnostics_current = self
.text_workspace
.diagnostic_publication()
.is_some_and(|publication| publication.source() == self.source_key());
self.source_diagnostics
.retain(|issue| issue.diagnostic.code != "pending_save");
if diagnostics_current {
self.publish_source_diagnostics(self.source_diagnostics.clone());
}
match receipt.disk {
Ok(disk) if !self.source_workspace.applied => {
self.open_source_project(disk)
@@ -3338,7 +3417,7 @@ impl App {
|| self.path.as_ref() == Some(&receipt.target)
{
if self.path.as_ref() != Some(&receipt.target) {
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
}
self.path = Some(receipt.target);
self.document.bind_repair_baseline(disk.reachable_bundle());
@@ -3366,13 +3445,24 @@ impl App {
if version == self.source_version {
match result {
Ok(disk) => {
// Discovery must include the exact overlays retained
// by this workspace. A current request version alone
// does not prove arbitrary reply spans match them.
if disk.bundle.entry != self.source_workspace.entry
|| self.source_workspace.files.iter().any(|(path, buffer)| {
disk.bundle.files.get(path) != Some(&buffer.text)
})
{
self.status = "Included-file results no longer match the retained drafts. Load includes again.".into();
return self.request_lint();
}
if let Some(target) = recovery_target(&disk) {
self.pending_recovery = Some(target);
}
self.source_workspace
.discovered(&disk.bundle, disk.reachable.clone());
self.source_version += 1;
self.source_diagnostics = disk.diagnostics.clone();
self.publish_source_diagnostics(disk.diagnostics.clone());
self.disk_project = Some(disk);
self.status = "Included files loaded; existing drafts retained.".into();
return self.request_lint();
@@ -3393,21 +3483,13 @@ impl App {
self.lint_running = None;
if version == self.source_version {
match result {
Ok(diagnostics) => self.source_diagnostics = diagnostics,
Ok(diagnostics) => self.publish_source_diagnostics(diagnostics),
Err(error) => self.status = format!("Source checks failed: {error}"),
}
}
return self.start_pending_lint();
}
Message::SourceDiagnostic(index) => {
if let Some(issue) = self.source_diagnostics.get(index) {
self.jump_to_source(SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
});
}
}
Message::SourceRelated(location) => self.jump_to_source(location),
Message::TextWorkspace(_) => return Task::none(), // checked before generic input
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
@@ -3449,7 +3531,7 @@ impl App {
if self.source_workspace.undo(redo) {
self.show_source_file(true);
self.source_version += 1;
self.source_diagnostics.clear();
self.publish_source_diagnostics(Vec::new());
return self.request_lint();
}
}
@@ -3597,7 +3679,7 @@ impl App {
self.sync_source();
}
if self.path.as_ref() != Some(&receipt.path) {
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
@@ -4139,7 +4221,7 @@ impl App {
self.source_workspace
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
self.migration = self.document.compiled().decoded.migration.clone();
if self.migration.is_some() {
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
@@ -4159,9 +4241,10 @@ impl App {
self.path = Some(path);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.source_workspace = Workspace::new(&disk.bundle, false);
self.clear_source_presentation();
self.source_workspace.set_reachable(disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = error.diagnostics().to_vec();
self.publish_source_diagnostics(error.diagnostics().to_vec());
self.source_version += 1;
self.lint_requested = None;
self.page = Page::Text;
@@ -4445,7 +4528,7 @@ impl App {
}
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
if self.connection.is_some() {
if self.connection.is_some() || self.page != Page::Diagram {
return;
}
let Some(context) = self.route_context() else {
@@ -4473,6 +4556,75 @@ impl App {
.filter(|(id, _)| *id == self.selected)
.map(|(_, i)| i);
let command = match event {
Event::ChooseAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_some()
{
self.selected_attachment = Some((context, self.selected, end));
self.selected_bend = None;
self.status = format!(
"Drag the {} attachment handle along its element boundary · Esc cancels",
if end == EdgeEnd::Source {
"source"
} else {
"target"
}
);
}
return;
}
Event::ResetAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none()
|| route.attachment(end).is_none()
{
return;
}
route.set_attachment(end, None);
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::CommitAttachment(expected, changed, end, owner) => {
if expected != context
|| changed.edge != self.selected
|| crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none_or(|node| node.id != owner)
{
return;
}
// A boundary gesture owns exactly one presentation field.
let mut intended = route.clone();
intended.set_attachment(end, changed.attachment(end));
if changed != intended || intended == route {
return;
}
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route: intended,
}
}
Event::SelectBend(id, index) => {
if id == self.selected && index < route.waypoints.len() {
self.selected_bend = Some((id, index));
@@ -5226,7 +5378,16 @@ mod source_project_tests {
assert_eq!(app.project(), &project);
assert!(!app.apply_source());
assert_eq!(app.source_diagnostics[0].path, fragment);
send(&mut app, Message::SourceDiagnostic(0));
send(&mut app, Message::Page(Page::Text));
let _ = app.show_text_drawer(Some(text_workspace::DrawerTab::Problems));
let issue = &app.source_diagnostics[0];
let message = app
.text_diagnostic_message(SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
})
.unwrap();
send(&mut app, message);
assert_eq!(app.source_workspace.active, fragment);
assert_eq!(app.source_editor.text(), "? invalid source");
send(&mut app, Message::RevertSource);
@@ -5268,6 +5429,275 @@ mod source_project_tests {
);
assert!(!app.dirty());
}
fn open_missing_function_file(directory: &Directory) -> App {
let entry = format!(
"arcadia {}; project p \"P\" {{ include \"work.arcadia\"; }}\n",
oa_core::SCHEMA_VERSION
);
let path = directory.0.join("project.arcadia");
std::fs::write(&path, entry).unwrap();
let mut app = App::with_project(Project::demo());
send(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
);
assert!(!app.source_workspace.applied);
assert!(!app.source_workspace.files.contains_key("work.arcadia"));
app
}
fn assert_raw_second_warning(app: &App, raw: &str) {
let issue = app
.source_diagnostics
.iter()
.find(|issue| {
issue.path == "work.arcadia"
&& issue.diagnostic.code == "unallocated_leaf"
&& issue.diagnostic.message.contains("Second")
})
.unwrap();
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\" {}"
);
assert_eq!(
app.text_workspace
.diagnostic_publication()
.unwrap()
.source(),
app.source_key()
);
}
#[test]
fn includes_loaded_diagnostics_use_raw_newly_discovered_bytes() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
let model = app.document.snapshot();
let publication = app.text_workspace.diagnostic_publication();
let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
std::fs::write(directory.0.join("work.arcadia"), raw).unwrap();
let disk = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
// Real producer control: compiled diagnostics would refer to inserted
// IDs, while IncludesLoaded must leave the raw draft unapplied.
let compiled = source_project::compile_bundle(&disk.reachable_bundle(), LIMITS).unwrap();
assert_ne!(compiled.bundle, disk.bundle);
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(disk)));
assert_raw_second_warning(&app, raw);
assert_eq!(app.source_workspace.files["work.arcadia"].text, raw);
assert!(!app.source_workspace.applied);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
assert_ne!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(
std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(),
raw
);
}
#[test]
fn includes_loaded_diagnostics_use_retained_overlay_and_ignore_old_version() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
std::fs::write(directory.0.join("work.arcadia"), raw).unwrap();
let first = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(first)));
let model = app.document.snapshot();
let previous = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let previous_version = app.source_version;
send(&mut app, Message::SourceFile("work.arcadia".into()));
let edited = format!("// Retained Unicode draft: 航海\n{raw}");
edit(&mut app, &edited);
let current = source_workspace::lint(&app.source_workspace.bundle());
app.publish_source_diagnostics(current);
let publication = app.text_workspace.diagnostic_publication();
let key = app.source_key();
send(
&mut app,
Message::IncludesLoaded(previous_version, Ok(previous)),
);
assert_eq!(app.source_key(), key);
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_raw_second_warning(&app, &edited);
std::fs::write(directory.0.join("work.arcadia"), "// external disk edit\n").unwrap();
let discovered = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
assert_eq!(discovered.bundle.files["work.arcadia"], edited);
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(discovered)));
assert_raw_second_warning(&app, &edited);
assert_ne!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_editor.text(), edited);
assert_eq!(app.source_workspace.files["work.arcadia"].text, edited);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
assert_eq!(
std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(),
"// external disk edit\n"
);
}
#[test]
fn includes_loaded_rejects_mismatched_retained_bytes_before_publication() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
std::fs::write(
directory.0.join("work.arcadia"),
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
)
.unwrap();
// Discovery can also report a pending recovery target. A rejected
// reply must not install that metadata either.
std::fs::create_dir(directory.0.join(".arcadia-save-project.arcadia.journal")).unwrap();
let reply = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
assert!(recovery_target(&reply).is_some());
let edited = format!("// retained entry draft\n{}", app.source_editor.text());
edit(&mut app, &edited);
let current = source_workspace::lint(&app.source_workspace.bundle());
app.publish_source_diagnostics(current);
assert!(!app.source_diagnostics.is_empty());
let publication = app.text_workspace.diagnostic_publication();
let diagnostics = app.source_diagnostics.clone();
let before = app.source_workspace.bundle();
let key = app.source_key();
let reachable = app.source_workspace.reachable.clone();
let disk = app.disk_project.clone().unwrap();
let model = app.document.snapshot();
let recovery = app.pending_recovery.clone();
// Deliberately stamp an older real producer reply with the current
// version: this tests byte admission independently of version refusal.
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(reply)));
assert_eq!(app.source_version, version);
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.bundle(), before);
assert_eq!(app.source_workspace.reachable, reachable);
assert!(!app.source_workspace.files.contains_key("work.arcadia"));
assert_eq!(app.source_editor.text(), edited);
assert_eq!(app.pending_recovery, recovery);
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_diagnostics, diagnostics);
let after = app.disk_project.as_ref().unwrap();
assert_eq!(after.root, disk.root);
assert_eq!(after.bundle, disk.bundle);
assert_eq!(after.expectations, disk.expectations);
assert_eq!(after.reachable, disk.reachable);
assert_eq!(after.diagnostics, disk.diagnostics);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
#[test]
fn includes_loaded_requires_each_inactive_detached_overlay() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
let original = "sa_function first \"First\" {}\n";
std::fs::write(directory.0.join("work.arcadia"), original).unwrap();
let discovered = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(discovered)));
send(&mut app, Message::SourceFile("work.arcadia".into()));
let retained = format!("// retained detached draft\n{original}");
edit(&mut app, &retained);
send(&mut app, Message::SourceFile("project.arcadia".into()));
let entry = format!(
"arcadia {}; project p \"P\" {{}}\n",
oa_core::SCHEMA_VERSION
);
edit(&mut app, &entry);
assert!(app.apply_source(), "{}", app.status);
assert_eq!(app.source_workspace.active, app.source_workspace.entry);
assert!(!app.source_workspace.reachable.contains("work.arcadia"));
assert_eq!(app.source_workspace.files["work.arcadia"].text, retained);
let valid = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let before = app.source_workspace.bundle();
let key = app.source_key();
let publication = app.text_workspace.diagnostic_publication();
let diagnostics = app.source_diagnostics.clone();
let disk = app.disk_project.clone().unwrap();
let model = app.document.snapshot();
for missing in [false, true] {
let mut reply = valid.clone();
if missing {
reply.bundle.files.remove("work.arcadia");
} else {
reply
.bundle
.files
.insert("work.arcadia".into(), original.into());
}
// Entry and active text match exactly. Only an inactive, detached
// overlay is wrong or absent; checking active/entry alone would pass.
assert_eq!(
reply.bundle.files[&app.source_workspace.entry],
app.source_editor.text()
);
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(reply)));
assert_eq!(app.source_version, version);
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.bundle(), before);
assert!(!app.source_workspace.reachable.contains("work.arcadia"));
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_diagnostics, diagnostics);
let after = app.disk_project.as_ref().unwrap();
assert_eq!(after.bundle, disk.bundle);
assert_eq!(after.expectations, disk.expectations);
assert_eq!(after.reachable, disk.reachable);
assert_eq!(after.diagnostics, disk.diagnostics);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
// Exact overlays remain admissible even when the retained draft is
// detached and its external file still contains the original bytes.
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(valid)));
assert_eq!(app.source_version, version + 1);
assert_ne!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_workspace.bundle(), before);
assert_eq!(app.source_workspace.files["work.arcadia"].text, retained);
assert_eq!(
std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(),
original
);
}
#[test]
fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() {
let directory = Directory::new();
@@ -69,6 +69,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
@@ -75,6 +75,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -230,6 +230,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
@@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
+2 -2
View File
@@ -219,7 +219,7 @@ impl App {
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
@@ -359,7 +359,7 @@ impl App {
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
}
self.hosted.session = Some(Session {
connection,
+1 -1
View File
@@ -215,7 +215,7 @@ impl App {
}
}
fn rename_admission(&self) -> Result<(), String> {
pub(super) fn rename_admission(&self) -> Result<(), String> {
if self.page != Page::Text
|| self.busy
|| self.pending.is_some()
@@ -359,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
send(&mut app, Message::SourceSymbolsReady(source_ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_symbols.open);
assert!(app.text_workspace.drawer().is_none());
send(&mut app, Message::DiagramUsagesReady(old_diagram));
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
assert!(app.diagram_usages.as_ref().unwrap().loading);
@@ -164,3 +164,125 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
first_measurement(&mut app, &found);
}
#[cfg(debug_assertions)]
#[test]
fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
for changed in 0..3 {
let mut app = navigation_fixture();
app.text_workspace.show_drawer(Some(DrawerTab::Status));
let origin = app.text_drawer_context();
let captured = [
Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)),
Message::TextWorkspace(TextEvent::HideDrawer(origin)),
crate::views::captured_text_drawer_resize(&app),
];
match changed {
0 => app.canvas_document = Id::new_v4(),
1 => app.source_workspace.reset_navigation_session(),
_ => app.text_workspace.show_drawer(Some(DrawerTab::Status)),
}
let found = navigate(&mut app);
// Keep command availability true to isolate the stale owner check from
// the page guard, while a newer measured navigation is still pending.
app.page = Page::Text;
assert!(app.source_navigation_available());
let destination = navigation(&app);
let allocation = app.allocation_boundary();
let camera = app.camera_boundary();
let allocation_epoch = app.allocation_drag.epoch;
let interaction = app.source_symbols.interaction;
let drawer = app.text_workspace.drawer();
let height = app.text_workspace.drawer_height();
let visit = app.text_workspace.drawer_visit();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let status = app.status.clone();
for message in captured {
send(&mut app, message);
assert_eq!(navigation(&app), destination);
assert!(app.allocation_boundary() == allocation);
assert!(app.camera_boundary() == camera);
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.text_workspace.drawer(), drawer);
assert_eq!(app.text_workspace.drawer_height(), height);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.status, status);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
app.page = Page::Diagram;
first_measurement(&mut app, &found);
}
}
#[cfg(debug_assertions)]
#[test]
fn current_compact_drawer_controls_and_mounted_resize_remain_live() {
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
let mut app = navigation_fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
navigate(&mut app);
app.page = Page::Text;
assert!(app.pending_diagram_reveal.is_some());
let epoch = app.allocation_drag.epoch;
let show = Message::TextWorkspace(TextEvent::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Status,
));
send(&mut app, show);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
assert!(app.pending_diagram_reveal.is_none());
assert!(app.diagram_navigation_focus.is_none());
assert!(app.allocation_drag.epoch > epoch);
let epoch = app.allocation_drag.epoch;
let height = app.text_workspace.drawer_height();
let resize = crate::views::captured_text_drawer_resize(&app);
send(&mut app, resize);
assert_eq!(app.text_workspace.drawer_height(), height + 60.);
assert!(app.allocation_drag.epoch > epoch);
let epoch = app.allocation_drag.epoch;
let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
send(&mut app, hide);
assert!(app.text_workspace.drawer().is_none());
assert!(app.allocation_drag.epoch > epoch);
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 compact_diagram_usages_command_uses_captured_query_and_existing_chooser() {
let mut app = navigation_fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages);
send(&mut app, command);
let request = app
.source_symbols
.request
.clone()
.expect("actual source query started");
assert_eq!(app.source_diagram_job, Some(request.job));
let completion = source_completion(&app, request);
send(&mut app, Message::SourceSymbolsReady(completion));
let panel = app
.diagram_usages
.as_ref()
.expect("existing guarded chooser opened");
assert_eq!(panel.context.subject, model_id(2));
assert_eq!(panel.context.document, app.canvas_document);
assert_eq!(panel.context.revision, app.document.revision());
assert_eq!(app.diagram_lookup_in_flight, Some(panel.token));
assert!(!app.source_navigation_available());
let (_, found) = finish_lookup(&mut app);
assert_eq!(found.surface, usage::Surface::Saved(model_id(8)));
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(app.source_symbols.back.is_empty());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
@@ -512,3 +512,45 @@ async fn details_remount_restores_wheel_offset_before_any_rescroll() {
assert_eq!(evidence(&app), before);
}
}
#[tokio::test]
async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() {
let mut app = paged_fixture();
app.publish_source_diagnostics(app.source_diagnostics.clone());
let publication = app.text_workspace.diagnostic_publication().unwrap();
let before = evidence(&app);
let interaction = app.source_symbols.interaction;
let camera = app.camera_boundary();
let allocation = app.allocation_boundary();
let cursor = app.source_editor.cursor();
let mut mounted = Mounted::new(&app);
let reports = mounted.wheel(&app);
assert_eq!(reports.len(), 1);
let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else {
panic!("actual wheel must publish the retained preview measurement");
};
assert!(offset.is_finite() && *offset > 0.);
app.publish_source_diagnostics(app.source_diagnostics.clone());
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_ne!(current.generation(), publication.generation());
assert_eq!(current.source(), publication.source());
assert!(app.source_annotation_snapshot().is_some());
assert!(
!app.source_navigation_available(),
"rename still owns modal input"
);
for message in reports.iter().cloned() {
let task = app.update(message);
assert_eq!(mounted.execute(&app, task).await, 0);
}
assert_eq!(
app.source_rename.form.as_ref().unwrap().preview_offset,
*offset
);
assert_eq!(mounted.offset(&app), offset.round());
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.camera_boundary() == camera);
assert!(app.allocation_boundary() == allocation);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(evidence(&app), before);
}
@@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist
assert_ne!(app.source_navigation.label(), "0 matches");
let mut invalid = original_source.clone();
invalid.files.insert(path.clone(), "?".into());
app.source_diagnostics = source_workspace::lint(&invalid);
app.publish_source_diagnostics(source_workspace::lint(&invalid));
assert!(!app.source_diagnostics.is_empty());
let version = app.source_version;
app.lint_requested = Some(version);
@@ -839,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
send(&mut app, Message::SourceRenameReady(ready.clone()));
}
app.source_navigation.open = true;
app.source_symbols.open = true;
app.text_workspace
.show_drawer(Some(crate::app::text_workspace::DrawerTab::References));
match other_modal {
1 => app.hosted.visible = true,
2 => {
@@ -866,7 +867,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
}
assert!(!app.source_rename.open());
assert!(
app.source_symbols.open && app.source_navigation.open,
app.text_workspace.drawer().is_some() && app.source_navigation.open,
"rename Escape does not fall through"
);
if pending {
@@ -875,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
assert!(!app.source_rename.open());
}
assert_eq!(evidence(&app), before);
app.source_symbols.open = false;
app.text_workspace.show_drawer(None);
app.source_navigation.open = false;
assert!(captured_escape(&app).is_empty());
}
+100 -20
View File
@@ -33,9 +33,29 @@ fn admission_notice(error: query::AdmissionError) -> String {
impl App {
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
match message {
Message::SourceSymbols(context, _) => {
Message::SourceSymbols(context, event) => {
context.session == self.source_workspace.session()
&& context.path == self.source_workspace.active
&& match event {
nav::SymbolEvent::Choose { stamp, visit, .. } => {
self.text_workspace.drawer()
== Some(text_workspace::DrawerTab::References)
&& self.source_navigation_available()
&& stamp.snapshot == self.source_key()
&& *visit == self.text_workspace.drawer_visit()
&& self
.source_symbols
.results
.as_ref()
.is_some_and(|results| results.stamp == *stamp)
&& self
.source_symbols
.cache
.as_ref()
.is_some_and(|cache| self.source_snapshot_matches(cache))
}
_ => true,
}
}
Message::SourceFindClose(context, instance) => {
context == &self.source_symbol_context()
@@ -106,6 +126,20 @@ impl App {
&& self.with_source_input(|input| snapshot.matches(input))
}
/// Retained rows remain readable after departure, but are current only while
/// their captured source bytes still match the complete workspace.
pub fn source_results_current(&self) -> bool {
self.source_symbols
.results
.as_ref()
.is_some_and(|results| results.stamp.snapshot == self.source_key())
&& self
.source_symbols
.cache
.as_ref()
.is_some_and(|cache| self.source_snapshot_matches(cache))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.source_rename.open()
@@ -118,6 +152,7 @@ impl App {
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.text_workspace.menu().is_none()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
@@ -133,6 +168,12 @@ impl App {
))
}
/// Capture the same source-owned query used by the keyboard/menu adapter.
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
self.source_navigation_available()
.then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
@@ -215,7 +256,8 @@ impl App {
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
self.source_symbols.open = true;
self.text_workspace
.show_drawer(Some(text_workspace::DrawerTab::References));
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
return Task::none();
}
@@ -227,21 +269,28 @@ impl App {
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
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();
@@ -279,7 +328,8 @@ impl App {
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.source_symbols.open = true;
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),
@@ -300,7 +350,7 @@ impl App {
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
@@ -340,9 +390,12 @@ impl App {
};
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;
Task::perform(
let worker = Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
@@ -373,7 +426,14 @@ impl App {
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> {
@@ -386,11 +446,11 @@ impl App {
if diagram_query {
self.source_diagram_job = None;
}
let wanted = self
let wanted_navigation = self
.source_symbols
.request
.take()
.is_some_and(|current| current.job == request.job)
.is_some_and(|current| current == request)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
@@ -400,15 +460,34 @@ impl App {
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
if !wanted {
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) => {
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());
if diagram_query {
if results.disposition == query::Disposition::Direct
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 diagram_query && wanted_navigation {
if wanted_navigation
&& results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
@@ -419,7 +498,7 @@ impl App {
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.source_symbols.open = false;
self.text_workspace.show_drawer(None);
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
@@ -431,7 +510,8 @@ impl App {
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if results.disposition == query::Disposition::Direct
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);
+557 -81
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::collections::BTreeMap;
@@ -37,6 +38,7 @@ fn fixture() -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
@@ -50,6 +52,28 @@ fn fixture() -> App {
app
}
fn select_effective_pump(app: &mut App, line: bool) -> String {
let source = app.source_workspace.text();
let offset = source.find("source @pump").unwrap() + "source @pu".len();
app.source_editor.move_to(text_editor::Cursor {
position: position(source, offset),
selection: None,
});
send(
app,
Message::SourceEdit(if line {
text_editor::Action::SelectLine
} else {
text_editor::Action::SelectWord
}),
);
let selected = app.source_editor.selection().unwrap();
assert!(selected.contains("pump"));
let raw = app.source_editor.cursor();
assert_eq!(raw.selection, Some(raw.position));
selected
}
fn send(app: &mut App, message: Message) {
// Dropping the returned Task does not poll its worker/lint future. Completion
// below is deterministic and still traverses the real App message handler.
@@ -61,6 +85,23 @@ fn symbol(app: &mut App, event: nav::SymbolEvent) {
send(app, message);
}
fn close_drawer(app: &mut App) {
let message = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
send(app, message);
}
fn choice(app: &App, stamp: query::QueryStamp, reference: bool, index: usize) -> Message {
app.source_symbol_message(nav::SymbolEvent::Choose {
stamp,
visit: app.text_workspace.drawer_visit(),
reference,
index,
})
}
fn references_visible(app: &App) -> bool {
app.text_workspace.drawer() == Some(DrawerTab::References)
}
fn show(app: &mut App, path: &str) {
send(app, Message::SourceFile(path.into()));
reveal(app);
@@ -99,6 +140,9 @@ fn begin(app: &mut App, action: query::Action) -> nav::Request {
.clone()
.expect("request admitted");
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
assert!(references_visible(app));
request
}
@@ -165,14 +209,8 @@ fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged()
"references require a choice"
);
let second = uses.references[1].clone();
symbol(
&mut app,
nav::SymbolEvent::Choose {
stamp: uses.stamp,
reference: true,
index: 1,
},
);
let message = choice(&app, uses.stamp, true, 1);
send(&mut app, message);
reveal(&mut app);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
@@ -213,14 +251,8 @@ fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_v
results.definitions[index].membership,
query::Membership::Detached
);
symbol(
&mut app,
nav::SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
},
);
let message = choice(&app, results.stamp, false, index);
send(&mut app, message);
reveal(&mut app);
assert_eq!(app.source_workspace.active, "detached.arcadia");
assert_eq!(app.source_workspace.bundle(), loaded);
@@ -268,14 +300,8 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
.iter()
.position(|row| row.declaration.alias == "pump")
.unwrap();
symbol(
&mut app,
nav::SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
},
);
let message = choice(&app, results.stamp, false, index);
send(&mut app, message);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
@@ -300,11 +326,12 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
}
#[test]
fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes() {
fn completion_retains_current_display_but_rejects_navigation_after_context_changes() {
for change in 0..7 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
match change {
0 => show(&mut app, "project.arcadia"),
@@ -328,23 +355,38 @@ fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes
let after = app.source_workspace.bundle();
let active = app.source_workspace.active.clone();
let cursor = app.source_editor.cursor();
let drawer = app.text_workspace.drawer();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(
app.source_symbols.in_flight.is_none(),
"case {change}: release worker slot"
);
assert!(
app.source_symbols.results.is_none(),
"case {change}: stale result surfaced"
if matches!(change, 1 | 2) {
assert!(
app.source_symbols.results.is_none(),
"case {change}: changed source/session cannot own display"
);
} else {
assert!(
Arc::ptr_eq(app.source_symbols.results.as_ref().unwrap(), &expected),
"case {change}: canceled navigation still owns current-source display"
);
}
assert_eq!(
app.text_workspace.drawer(),
drawer,
"completion cannot select a tab"
);
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, active);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), after);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() {
let mut app = fixture();
@@ -368,25 +410,41 @@ fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_ne
}
#[test]
fn close_during_busy_hides_panel_and_completion_does_not_reopen_it() {
fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
assert!(app.source_symbols.open);
symbol(&mut app, nav::SymbolEvent::Close);
assert!(!app.source_symbols.open);
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
assert!(references_visible(&app));
close_drawer(&mut app);
assert!(app.text_workspace.drawer().is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(!app.source_symbols.open);
assert!(app.text_workspace.drawer().is_none());
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let next = begin(&mut app, query::Action::References);
assert_ne!(next.job, request.job);
assert!(app.source_symbols.open);
assert!(references_visible(&app));
assert!(
app.source_symbols.results.is_none(),
"explicit new query replaces prior display ownership"
);
}
#[test]
fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() {
let mut app = fixture();
@@ -420,7 +478,7 @@ fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal()
}
#[test]
fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources() {
fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
for pending in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
@@ -428,32 +486,42 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
if !pending {
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
assert!(app.source_symbols.results.is_some());
}
let results = app.source_symbols.results.clone();
app.source_navigation.open = true;
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::Escape);
assert!(app.source_symbols.open && !app.source_navigation.open);
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("Find Escape must retain underlying results"),
}
assert!(app.source_symbols.request.is_none());
reveal(&mut app);
send(&mut app, Message::Escape);
assert!(!app.source_symbols.open && !app.source_navigation.open);
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.notice.is_none());
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
assert_eq!(app.source_symbols.results.is_some(), !pending);
assert!(app.source_symbols.request.is_none());
// Explicit Escape may restore editor focus; consume that ticket before
// delivering completion so any later focus request is discriminated.
reveal(&mut app);
if pending {
assert_eq!(app.source_symbols.in_flight, Some(request.job));
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_symbols.open);
assert!(app.source_symbols.results.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
@@ -462,7 +530,6 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn escape_dismisses_modal_before_underlying_source_results() {
let mut app = fixture();
@@ -476,14 +543,17 @@ fn escape_dismisses_modal_before_underlying_source_results() {
app.source_reveal_ticket().is_none(),
"modal dismissal must not focus the editor"
);
assert!(app.source_symbols.open);
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
send(&mut app, Message::Escape);
assert!(!app.source_symbols.open);
assert!(app.source_symbols.results.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
@@ -496,8 +566,8 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
let origin = app.source_editor.cursor();
finish(&mut app, query::Action::Definition);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Close);
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
close_drawer(&mut app);
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
if changed {
// Use the workspace edit API to give the origin a new generation.
show(&mut app, USES);
@@ -517,7 +587,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
let raw = app.source_workspace.bundle();
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_symbols.open, changed);
assert_eq!(references_visible(&app), changed);
assert_eq!(app.source_symbols.notice.is_some(), changed);
reveal(&mut app);
if !changed {
@@ -529,7 +599,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
}
let mut app = fixture();
symbol(&mut app, nav::SymbolEvent::Back);
assert!(app.source_symbols.open && app.source_symbols.notice.is_some());
assert!(references_visible(&app) && app.source_symbols.notice.is_some());
}
#[test]
@@ -540,12 +610,12 @@ fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection()
let cursor = app.source_editor.cursor();
app.source_navigation.open = true;
app.source_navigation.query = "送水 🚀".into();
assert!(!app.source_symbols.open);
assert!(!references_visible(&app));
let mut messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!app.source_navigation.open && !app.source_symbols.open);
assert!(!app.source_navigation.open && !references_visible(&app));
assert_eq!(app.source_navigation.query, "送水 🚀");
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
@@ -790,6 +860,10 @@ async fn find_close_releases_removed_input_and_guarded_focus_respects_later_inpu
/// file dialog or App task is executed by this helper. Require the canvas keyboard
/// notification separately, then return every command routed to an Escape owner.
fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
input_subscription_messages(app, true)
}
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
@@ -805,9 +879,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
if focused {
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),
@@ -829,7 +905,8 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
let captured = shell.is_event_captured();
assert_eq!(captured, focused);
assert!(input_messages.is_empty());
assert!(
!tree
@@ -840,7 +917,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: iced::event::Status::Captured,
status: if captured {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
},
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
@@ -888,7 +969,9 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
}
let results = app.source_symbols.results.clone();
let expected = ready.result.as_ref().unwrap().1.clone();
app.source_navigation.open = true;
let visit = app.text_workspace.drawer_visit();
match modal {
0 => {}
1 => app.hosted.visible = true,
@@ -908,7 +991,12 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
send(&mut app, messages.remove(0));
if modal != 0 {
assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none());
assert!(app.source_symbols.open && app.source_navigation.open);
assert!(references_visible(&app) && app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(
app.source_reveal_ticket().is_none(),
"modal Escape cannot steal editor focus"
);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
@@ -919,24 +1007,38 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
}
assert!(app.source_symbols.open && !app.source_navigation.open);
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("Find Escape must retain underlying results"),
}
let mut messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1, "references still own the next Escape");
assert_eq!(app.source_symbols.results.is_some(), !pending);
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
// One more actual Escape hides the remaining drawer. Both status
// routes emit exactly one App message; no second bridge is needed.
let mut messages = input_subscription_messages(&app, false);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!app.source_symbols.open && !app.source_navigation.open);
assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none());
assert!(!references_visible(&app) && !app.source_navigation.open);
assert!(app.source_symbols.request.is_none());
reveal(&mut app);
if pending {
assert_eq!(app.source_symbols.in_flight, Some(request.job));
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
@@ -953,7 +1055,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
#[test]
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
let mut app = fixture();
app.source_symbols.open = true;
app.text_workspace.show_drawer(Some(DrawerTab::References));
let project = app.project().clone();
assert!(
app.behavior
@@ -983,7 +1085,7 @@ fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_ro
assert!(app.architecture.as_ref().unwrap().inline());
// A retained Text panel must not add a second route to inline architecture.
app.page = Page::Text;
app.source_symbols.open = true;
app.text_workspace.show_drawer(Some(DrawerTab::References));
let messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(
@@ -1203,7 +1305,16 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message])
.collect();
let results = app.source_symbols.results.clone();
let cache = app.source_symbols.cache.clone();
let panel = (app.source_symbols.open, app.source_symbols.notice.clone());
let panel = (
app.text_workspace.drawer(),
app.source_symbols.notice.clone(),
);
let display_request = app.source_symbols.display_request.clone();
let reference_presentation = (
app.text_workspace.references().stamp(),
app.text_workspace.drawer_visit(),
app.text_workspace.references().scroll_y(),
);
let key = app.source_key();
let active = app.source_workspace.active.clone();
let cursor = app.source_editor.cursor();
@@ -1268,9 +1379,21 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message])
back
);
assert_eq!(
(app.source_symbols.open, app.source_symbols.notice.clone()),
(
app.text_workspace.drawer(),
app.source_symbols.notice.clone()
),
panel
);
assert_eq!(app.source_symbols.display_request, display_request);
assert_eq!(
(
app.text_workspace.references().stamp(),
app.text_workspace.drawer_visit(),
app.text_workspace.references().scroll_y()
),
reference_presentation
);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
@@ -1315,12 +1438,8 @@ fn stale_envelope_trial(reset: bool) {
let old_messages = [
app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)),
app.source_symbol_message(nav::SymbolEvent::Back),
app.source_symbol_message(nav::SymbolEvent::Close),
app.source_symbol_message(nav::SymbolEvent::Choose {
stamp: old_results.stamp,
reference: false,
index: 0,
}),
Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())),
choice(&app, old_results.stamp, false, 0),
];
if reset {
let project = app.project().clone();
@@ -1380,9 +1499,9 @@ fn stale_envelope_trial(reset: bool) {
// Replay once more with no busy-slot fallback and visible current results.
assert_stale_envelopes_preserve_current(&mut app, &old_messages);
let interaction = app.source_symbols.interaction;
symbol(&mut app, nav::SymbolEvent::Close);
close_drawer(&mut app);
assert_ne!(app.source_symbols.interaction, interaction);
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
let next = begin(&mut app, query::Action::References);
assert_ne!(
next.job, current.job,
@@ -1535,12 +1654,332 @@ fn process_memory() -> Option<(usize, usize)> {
(ok != 0).then_some((counters.working_set, counters.peak_working_set))
}
#[test]
fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or_focus() {
for destination in [None, Some(DrawerTab::Problems), Some(DrawerTab::Status)] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
assert_eq!(expected.disposition, query::Disposition::Direct);
let event = destination.map_or(TextEvent::HideDrawer(app.text_drawer_context()), |tab| {
TextEvent::ShowDrawer(app.text_drawer_context(), tab)
});
send(&mut app, Message::TextWorkspace(event));
assert_eq!(app.text_workspace.drawer(), destination);
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert_eq!(app.text_workspace.drawer(), destination);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
{
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
app.text_drawer_context(),
DrawerTab::References,
));
send(&mut app, captured);
};
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.in_flight.is_none() && app.source_symbols.request.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
}
}
#[test]
fn same_worker_with_changed_request_envelope_cannot_publish_or_navigate() {
for changed in 0..7 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let mut ready = completion(&app, request.clone());
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
match changed {
0 => ready.request.stamp.serial += 1,
1 => ready.request.path = DEFINITIONS.into(),
2 => ready.request.offset += 1,
3 => ready.request.cursor.selection = None,
4 => ready.request.interaction = Id::new_v4(),
5 => ready.request.raw_interaction += 1,
_ => ready.request.action = query::Action::References,
}
assert_eq!(ready.request.job, request.job);
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(
app.source_symbols.in_flight.is_none(),
"worker completed in case {changed}"
);
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn matching_old_worker_cannot_replace_a_newer_display_owner() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let old = begin(&mut app, query::Action::Definition);
let ready = completion(&app, old.clone());
let mut latest = old.clone();
latest.stamp.serial += 1;
latest.job = Id::new_v4();
// Isolate display ownership from both worker ownership and navigation intent.
app.source_interaction();
app.source_symbols.display_request = Some(latest.clone());
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&latest));
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let accepted = app.document.snapshot();
{
let captured = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
send(&mut app, captured);
};
// Deliberately violate the normal edit API's key advancement: the exact
// source witness must still reject stale bytes instead of trusting the key.
app.source_workspace
.files
.get_mut(USES)
.unwrap()
.text
.push_str("\r\n// changed bytes");
assert_eq!(app.source_key(), request.stamp.snapshot);
let changed = app.source_workspace.bundle();
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.text_workspace.drawer().is_none());
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(), changed);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn popup_escape_has_no_duplicate_bridge_and_dismissal_preserves_underlying_find_and_drawer() {
for line in [false, true] {
for captured_find_escape in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let results = finish(&mut app, query::Action::References);
let selected = select_effective_pump(&mut app, line);
let cursor = app.source_editor.cursor();
let group = TextEvent::ToggleFile {
stamp: results.stamp,
visit: app.text_workspace.drawer_visit(),
path: USES.into(),
};
send(&mut app, Message::TextWorkspace(group));
let visit = app.text_workspace.drawer_visit();
send(
&mut app,
Message::TextWorkspace(TextEvent::ReferenceScrolled {
stamp: results.stamp,
visit,
y: 96.,
}),
);
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
app.source_navigation.query = "Retain Find 雨".into();
let open = app.text_menu_message(iced::Point::new(30., 30.));
send(&mut app, open);
let menu = app.text_workspace_menu().expect("Text popup opened");
assert!(
captured_input_subscription_messages(&app).is_empty(),
"Popup owns captured Escape; a bridge Escape would also close its underlying UI"
);
// The popup itself publishes this tokened dismissal on Escape.
send(&mut app, menu.on_close);
assert!(app.text_workspace.menu().is_none());
assert!(references_visible(&app) && app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
let mut next = input_subscription_messages(&app, captured_find_escape);
assert_eq!(
next.len(),
1,
"exactly one message for either Escape status"
);
assert!(matches!(next[0], Message::Escape));
send(&mut app, next.remove(0));
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(
app.text_workspace.drawer_visit(),
visit,
"closing Find does not replace the drawer visit"
);
assert_eq!(app.source_navigation.query, "Retain Find 雨");
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
assert!(app.source_reveal_ticket().is_some() && app.source_jump.is_none());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
let mut last = input_subscription_messages(&app, false);
assert_eq!(last.len(), 1);
assert!(matches!(last[0], Message::Escape));
send(&mut app, last.remove(0));
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
assert_ne!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
assert_eq!(app.source_navigation.query, "Retain Find 雨");
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert!(captured_input_subscription_messages(&app).is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(app.source_symbols.back.is_empty());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn retained_choices_cannot_replay_after_hide_or_references_problems_references() {
for reference in [false, true] {
for hide in [false, true] {
let mut app = fixture();
let results = finish(&mut app, query::Action::References);
let index = usize::from(reference);
let old = choice(&app, results.stamp, reference, index);
let old_visit = app.text_workspace.drawer_visit();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let event = if hide {
TextEvent::HideDrawer(app.text_drawer_context())
} else {
TextEvent::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems)
};
send(&mut app, Message::TextWorkspace(event));
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert_stale_envelopes_preserve_current(&mut app, std::slice::from_ref(&old));
{
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
app.text_drawer_context(),
DrawerTab::References,
));
send(&mut app, captured);
};
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert_eq!(
app.source_key(),
results.stamp.snapshot,
"same source isolates the visit guard"
);
assert_stale_envelopes_preserve_current(&mut app, &[old]);
let fresh = choice(&app, results.stamp, reference, index);
send(&mut app, fresh);
assert!(
app.source_symbols.reveal.is_some(),
"freshly captured reopened choice remains usable"
);
assert_eq!(app.source_symbols.back.len(), 1);
reveal(&mut app);
let (path, span) = if reference {
let row = &results.references[index];
(&row.path, row.span)
} else {
let row = &results.definitions[index];
(&row.path, row.span)
};
assert_eq!(&app.source_workspace.active, path);
assert_eq!(
app.source_editor.cursor(),
selection(&raw.files[path], span.start, span.end, false)
);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() {
for modal in 0..5 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request);
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
@@ -1572,7 +2011,11 @@ fn combined_modal_completion_releases_worker_without_navigation_or_model_changes
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none(), "modal {modal}");
assert!(app.source_symbols.request.is_none());
assert!(app.source_symbols.results.is_none());
// Current-source display data may finish behind a modal; it grants no navigation.
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.back.is_empty());
assert!(app.source_reveal_ticket().is_none());
assert_eq!(app.source_workspace.active, USES);
@@ -1623,6 +2066,7 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
.move_to(selection(text, offset, offset + alias.len(), false));
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request);
let expected = ready.result.as_ref().unwrap().1.clone();
app.page = Page::Diagram;
send(&mut app, Message::FieldReferenceOpen(open));
assert!(app.reference_input_active(), "{}", app.status);
@@ -1635,7 +2079,11 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
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());
// Current-source display data may finish behind a modal; it grants no navigation.
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_reveal_ticket().is_none());
assert!(
app.reference_input_active(),
@@ -1645,3 +2093,31 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() {
for path in [USES, DEFINITIONS] {
let mut app = fixture();
let results = finish(&mut app, query::Action::References);
assert!(app.source_results_current());
#[cfg(debug_assertions)]
assert!(!crate::views::captured_source_reference_stale_summary(&app));
let captured = choice(&app, results.stamp, true, 0);
let key = app.source_key();
// Isolate byte authority from revision authority in both the displayed
// and an inactive buffer. Views share this exact currentness decision.
app.source_workspace
.files
.get_mut(path)
.unwrap()
.text
.push_str("\r\n// exact bytes changed");
assert_eq!(app.source_key(), key);
assert!(!app.source_results_current());
#[cfg(debug_assertions)]
assert!(crate::views::captured_source_reference_stale_summary(&app));
app.queue_source_reveal(app.source_editor.cursor(), false);
assert!(app.source_reveal_ticket().is_some());
assert_stale_envelopes_preserve_current(&mut app, &[captured]);
}
}
@@ -20,7 +20,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se
send(&mut app, messages.remove(0));
assert!(!app.source_navigation.open);
assert_eq!(app.source_navigation.query, "retain 雪");
assert!(app.source_symbols.open);
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
@@ -40,8 +40,15 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!app.source_symbols.open);
assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert_eq!(
app.source_symbols.notice.as_deref(),
Some("Keep this reference notice")
);
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
@@ -172,6 +172,24 @@ impl App {
)));
}
Event::Choose(tool) => {
if let Tool::AdjustAttachment { relation, end } = tool {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before adjusting an attachment.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
.is_none()
{
return Task::none();
}
self.selected = relation;
self.selected_node = None;
self.refresh_draft();
}
if !self.apply() {
return Task::none();
}
@@ -210,6 +228,22 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if self.execute(plan.command) {
+686
View File
@@ -0,0 +1,686 @@
//! 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,
}
/// Authority captured by the rendered drawer, independent of its source rows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DrawerContext {
pub document: Id,
pub session: Id,
pub visit: Id,
}
#[derive(Debug, Clone)]
pub enum Event {
ShowDrawer(DrawerContext, DrawerTab),
HideDrawer(DrawerContext),
ResizeDrawer(DrawerContext, 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 {
pub fn text_drawer_context(&self) -> DrawerContext {
DrawerContext {
document: self.canvas_document,
session: self.source_workspace.session(),
visit: self.text_workspace.drawer_visit(),
}
}
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.text_presentation_boundary(Event::OpenMenu(request.clone()));
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_presentation_boundary(Event::DismissMenu(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(context, tab) => {
if context != self.text_drawer_context() || !self.source_navigation_available() {
return Task::none();
}
self.text_presentation_boundary(Event::ShowDrawer(context, tab));
return self.show_text_drawer(Some(tab));
}
Event::HideDrawer(context) => {
if context != self.text_drawer_context() || !self.source_navigation_available() {
return Task::none();
}
self.text_presentation_boundary(Event::HideDrawer(context));
return self.show_text_drawer(None);
}
Event::ResizeDrawer(context, height) => {
if context == self.text_drawer_context()
&& 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.text_presentation_boundary(Event::ResizeDrawer(context, height));
self.source_interaction();
self.text_workspace.drawer_height = height;
}
}
}
Event::ToggleFile { stamp, visit, path } => {
let captured = Event::ToggleFile {
stamp,
visit,
path: path.clone(),
};
if self.source_navigation_available()
&& self.text_workspace.toggle_file(stamp, visit, path)
{
self.text_presentation_boundary(captured);
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.text_presentation_boundary(Event::Diagnostic(target.clone()));
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.text_presentation_boundary(Event::ShowDrawer(self.text_drawer_context(), tab));
self.show_text_drawer(Some(tab))
}
}
}
/// Local Text presentation changes do not mutate a camera, document or route.
/// After their own authority check, they still supersede pending navigation
/// and allocation through the root's existing input boundary hooks. Source
/// commands use update instead; passive measurements never enter this hook.
fn text_presentation_boundary(&mut self, event: Event) {
let message = Message::TextWorkspace(event);
self.reconcile_pending_diagram_reveal(&message);
self.allocation_before(&message);
}
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;
@@ -0,0 +1,611 @@
//! 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)]);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
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());
}
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
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)]);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
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());
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
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)]);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
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"
);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
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"
);
}
/// Use an actual pending-save journal and the real recovery worker. The optional
/// reload error is injected only after recovery succeeded, so both receipt arms
/// exercise the same matching target without relying on filesystem races.
fn recover_warning_fixture(stale: bool, reload_error: bool) {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
let journal = root.join(".arcadia-save-project.arcadia.journal");
let mut app = fixture();
save(&mut app, &path);
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1, "entry": "project.arcadia", "files": [{
"path": "project.arcadia", "before": true, "write": true
}]
}))
.unwrap(),
)
.unwrap();
std::fs::write(journal.join("0.before"), VALID).unwrap();
std::fs::write(
journal.join("0.after"),
format!("{VALID}// interrupted save\r\n"),
)
.unwrap();
let disk = oa_core::source_files::load_project(&path, LIMITS).unwrap();
app.pending_recovery = crate::app::recovery_target(&disk);
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
let pending = disk
.diagnostics
.into_iter()
.find(|issue| issue.diagnostic.code == "pending_save")
.unwrap();
// Current-warning control includes newer unsaved bytes too. Recovery of an
// applied workspace must retain its drafts, not reload over them.
let draft = format!("{VALID}// retained draft\r\n");
if !stale {
app.source_workspace
.edit(draft.clone(), app.source_editor.cursor());
app.show_source_file(false);
}
let retained_warning = warning(&app);
app.publish_source_diagnostics(vec![retained_warning.clone(), pending]);
let show = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, show);
let old = warning_messages(&app);
let publication = app.text_workspace.diagnostic_publication().unwrap();
if stale {
// This is the existing workspace-key departure seam: drafts advance
// the key while a previous diagnostic Vec can still await lint.
app.source_workspace
.edit(draft.clone(), app.source_editor.cursor());
app.show_source_file(false);
assert_ne!(publication.source(), app.source_key());
} else {
assert_eq!(publication.source(), app.source_key());
}
assert!(app.source_workspace.applied && app.source_workspace.has_edits());
reveal(&mut app);
let accepted = app.document.snapshot();
let revision = app.document.revision();
let raw = app.source_workspace.bundle();
let key = app.source_key();
let cursor = app.source_editor.cursor();
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::SourceRecover);
assert!(
app.busy,
"normal recovery request admitted with retained applied drafts"
);
let mut receipt = crate::app::recover_snapshot(path.clone()).unwrap();
assert_eq!(receipt.target, path);
assert!(receipt.disk.is_ok());
assert!(!journal.exists(), "real recovery consumed the journal");
if reload_error {
receipt.disk = Err("injected reload failure after successful recovery".into());
}
send(&mut app, Message::SourceRecovered(Ok(receipt)));
assert!(!app.busy && app.pending_recovery.is_none());
assert_eq!(app.source_diagnostics, vec![retained_warning]);
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.source_workspace.text(), draft);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Problems));
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert_eq!(std::fs::read_to_string(&path).unwrap(), VALID);
assert!(app.status.contains(if reload_error {
"files could not be reloaded"
} else {
"Current model and drafts are retained"
}));
if stale {
assert_eq!(
app.text_workspace.diagnostic_publication(),
Some(publication)
);
assert!(app.source_annotation_snapshot().is_none());
assert!(
app.text_diagnostic_message(SourceLocation {
path: app.source_diagnostics[0].path.clone(),
span: app.source_diagnostics[0].diagnostic.span,
})
.is_none(),
"recovery must not manufacture a fresh stale-warning action"
);
} else {
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_eq!(current.source(), key);
assert_ne!(current.generation(), publication.generation());
assert!(app.source_annotation_snapshot().is_some());
}
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());
}
if !stale {
for message in warning_messages(&app) {
send(&mut app, message);
assert!(
app.source_symbols.reveal.is_some(),
"current primary and related remain live"
);
reveal(&mut app);
}
}
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 recovery_retains_current_warning_authority_with_drafts_and_reload_failure() {
for reload_error in [false, true] {
recover_warning_fixture(false, reload_error);
}
}
#[test]
fn recovery_does_not_republish_stale_warnings_after_workspace_key_departure() {
for reload_error in [false, true] {
recover_warning_fixture(true, reload_error);
}
}
@@ -0,0 +1,877 @@
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();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
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();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
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();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
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"
);
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status);
send(&mut app, captured);
};
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 0.,
},
);
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
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());
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
send(&mut app, captured);
};
assert_eq!(app.text_workspace.drawer_height(), 180.);
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX);
send(&mut app, captured);
};
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);
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
send(&mut app, captured);
};
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.);
send(&mut app, captured);
};
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());
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.);
send(&mut app, captured);
};
assert!(app.source_symbols.request.is_none());
app.source_symbols.request = Some(request.clone());
{
let captured = Event::HideDrawer(app.text_drawer_context());
send(&mut app, captured);
};
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();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
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(app.text_drawer_context())
} else {
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References)
};
send(&mut app, event);
for reopened in [false, true] {
if reopened {
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
} 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 => {
{
let captured = Event::HideDrawer(app.text_drawer_context());
send(&mut app, captured);
};
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
}
1 => {
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
}
_ => {
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(&current.source, &app.source_snapshot));
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
}
#[test]
fn drawer_document_session_and_visit_guards_are_independently_required() {
for component in 0..3 {
for action in 0..3 {
let mut app = fixture();
app.text_workspace.show_drawer(Some(DrawerTab::Status));
let mut captured = app.text_drawer_context();
match component {
0 => captured.document = Id::new_v4(),
1 => captured.session = Id::new_v4(),
_ => captured.visit = Id::new_v4(),
}
app.queue_source_reveal(app.source_editor.cursor(), false);
let reveal = app.source_reveal_ticket();
assert!(reveal.is_some());
let interaction = app.source_symbols.interaction;
let cursor = app.source_editor.cursor();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let current = app.text_drawer_context();
let height = app.text_workspace.drawer_height();
let event = match action {
0 => Event::ShowDrawer(captured, DrawerTab::Problems),
1 => Event::HideDrawer(captured),
_ => Event::ResizeDrawer(captured, height + 60.),
};
send(&mut app, event);
assert_eq!(app.text_drawer_context(), current);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
assert_eq!(app.text_workspace.drawer_height(), height);
assert_eq!(app.source_reveal_ticket(), reveal);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[cfg(debug_assertions)]
#[test]
fn actual_chooser_blocks_diagnostic_actions_and_mounted_hover_until_closed() {
use oa_core::{language::Diagnostic, source_project::SourceDiagnostic, validation::Severity};
for republish in [false, true] {
let mut app = fixture();
assert!(app.source_workspace.text().starts_with("arcadia"));
app.publish_source_diagnostics(vec![SourceDiagnostic {
path: app.source_workspace.active.clone(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "union".into(),
message: "Union diagnostic details".into(),
object_id: None,
field: None,
line: 1,
column: 1,
span: Span { start: 0, end: 7 },
},
related: Vec::new(),
}]);
let show = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, show);
let old_target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap();
let mut mounted = crate::source_annotations::AppMounted::new(
app.source_annotation_snapshot().unwrap(),
app.source_navigation_available(),
app.source_symbols.raw_interaction.clone(),
);
mounted.assert_hover(true);
let open = app.diagram_usage_message(app.project().id);
let _ = app.update(open);
let token = app
.diagram_usages
.as_ref()
.expect("actual chooser opened")
.token;
assert!(!app.source_navigation_available());
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let interaction = app.source_symbols.interaction;
let reveal = app.source_reveal_ticket();
if republish {
app.publish_source_diagnostics(app.source_diagnostics.clone());
}
mounted.refresh(
app.source_annotation_snapshot().unwrap(),
app.source_navigation_available(),
);
mounted.assert_hover(false);
let _ = app.update(old_target.clone());
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.source_reveal_ticket(), reveal);
assert_eq!(app.diagram_usages.as_ref().unwrap().token, token);
assert_eq!(app.diagram_lookup_in_flight, Some(token));
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::DiagramUsage(crate::diagram_usage::Event::Close(
token,
)));
assert!(app.source_navigation_available());
mounted.refresh(
app.source_annotation_snapshot().unwrap(),
app.source_navigation_available(),
);
mounted.assert_hover(true);
if republish {
let interaction = app.source_symbols.interaction;
let reveal = app.source_reveal_ticket();
let _ = app.update(old_target);
assert_eq!(
app.source_symbols.interaction, interaction,
"old publication stays stale after modal closes"
);
assert_eq!(app.source_reveal_ticket(), reveal);
}
let target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap();
let _ = app.update(target);
assert!(
app.source_reveal_ticket().is_some(),
"fresh admitted location still navigates"
);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
@@ -43,8 +43,10 @@ pub struct State {
press: Option<Press>,
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
pan_from: Option<(mouse::Button, Point)>,
viewport: Option<Size>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
scene: RefCell<Option<CachedScene>>,
geometry: canvas::Cache,
paint_key: RefCell<Option<PaintKey>>,
@@ -76,6 +78,20 @@ enum Press {
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
start: Option<Point>,
},
Attachment {
relation: Id,
end: EdgeEnd,
placement: Id,
start: Point,
},
}
#[derive(Clone, Copy, PartialEq)]
struct AttachmentPreview {
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: EdgeAttachment,
}
struct Scene {
@@ -86,12 +102,14 @@ struct Scene {
struct CachedScene {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
scene: Arc<Scene>,
}
#[derive(PartialEq)]
struct PaintKey {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
selected: Id,
zoom: f32,
pan: Vector,
@@ -107,6 +125,8 @@ impl State {
fn cancel_gesture(&mut self) {
self.press = None;
self.preview = None;
self.attachment_preview = None;
self.attachment_viewport = None;
self.pan_from = None;
}
fn reset(&mut self) {
@@ -656,13 +676,23 @@ impl StateCanvas<'_> {
.gesture_active(state)
.then_some(state.preview)
.flatten();
let attachment_preview = self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
{
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some(preview) = attachment_preview {
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
route.set_attachment(preview.end, Some(preview.attachment));
crate::route_edit::with_route(&mut diagram, route);
}
if let Some((id, at)) = preview
&& let Some(node) = diagram.nodes.get(&id)
{
@@ -697,6 +727,7 @@ impl StateCanvas<'_> {
*state.scene.borrow_mut() = Some(CachedScene {
context: self.context,
preview,
attachment_preview,
scene: scene.clone(),
});
Some(scene)
@@ -858,10 +889,64 @@ impl StateCanvas<'_> {
fn gesture_active(&self, state: &State) -> bool {
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
}
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(Press::Attachment {
relation,
end,
placement,
start,
}) = state.press
else {
return;
};
if window.distance(start) < 3. {
// Returning to the press restores the exact persisted optional value,
// without quantizing an existing attachment or pinning an automatic one.
state.attachment_preview = None;
return;
}
let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end)
.filter(|owner| owner.id == placement)
else {
state.cancel_gesture();
return;
};
let diagram = &self.project.diagrams[&self.diagram];
let previous = state
.attachment_preview
.map(|preview| preview.attachment.side)
.or_else(|| {
diagram
.routes
.iter()
.find(|route| route.edge == relation)
.and_then(|route| route.attachment(end))
.map(|attachment| attachment.side)
});
let world = self.world(local);
if !world.x.is_finite() || !world.y.is_finite() {
state.cancel_gesture();
return;
}
let attachment =
routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale());
state.attachment_preview = Some(AttachmentPreview {
relation,
end,
placement,
attachment,
});
}
fn guidance(&self, state: &State, scene: &Scene, world: Option<Point>) -> String {
if !self.editable {
return "Pending edits · apply or cancel them before editing the diagram".into();
}
if let Tool::AdjustAttachment { relation, end } = self.tool {
return format!(
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
self.project.named(relation)
);
}
let hovered = world.and_then(|p| self.vertex_at(scene, p));
let press = self.gesture_active(state).then_some(state.press).flatten();
let source = self.gesture_active(state).then_some(state.source).flatten();
@@ -952,8 +1037,15 @@ impl canvas::Program<Event> for StateCanvas<'_> {
if !self.editable {
state.press = None;
state.preview = None;
state.attachment_preview = None;
state.source = None;
}
if state
.attachment_viewport
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
{
state.cancel_gesture();
}
// Winit positions are ordered events. The cursor supplied by Iced is the
// final cursor for an entire batch and cannot reinterpret earlier clicks.
let cursor = match event {
@@ -981,6 +1073,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.reset();
return None;
}
canvas::Event::Window(iced::window::Event::Resized(_)) => {
state.cancel_gesture();
state.viewport = None;
return None;
}
_ => cursor,
};
let window = cursor.position();
@@ -1142,6 +1239,26 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
Some(Action::capture())
}
Tool::AdjustAttachment { relation, end } => {
let owner =
state_edit::attachment_node(self.project, self.diagram, relation, end)?;
let route = scene.routed.edges.get(&relation)?;
let tip = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}?;
if world.distance(point(*tip)) > 10. / self.scale() {
return None;
}
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
state.press = Some(Press::Attachment {
relation,
end,
placement: owner.id,
start: window,
});
Some(Action::capture())
}
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
@@ -1156,6 +1273,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
}
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
}
Some(Action::request_redraw())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
@@ -1176,9 +1296,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if let Some(window) = window {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
}
}
let press = state.press.take()?;
state.attachment_viewport = None;
let preview = state.preview.take();
let attachment_preview = state.attachment_preview.take();
let world = local.map(|p| self.world(p));
let event = match press {
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
@@ -1186,6 +1311,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
node,
position,
}),
Press::Attachment {
relation,
end,
placement,
..
} if inside => attachment_preview
.filter(|preview| {
preview.relation == relation
&& preview.end == end
&& preview.placement == placement
})
.map(|preview| Event::SetAttachment {
context: self.context,
relation,
end,
placement,
attachment: Some(preview.attachment),
}),
Press::Create { owner, kind, start }
if inside
&& window?.distance(start) < 3.
@@ -1282,6 +1425,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.gesture_active(state)
.then_some(state.preview)
.flatten(),
attachment_preview: self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten(),
selected: self.selected,
zoom: self.scale(),
pan: self.pan,
@@ -1391,6 +1538,20 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.with_width(2.5 / self.scale()),
);
}
if let Tool::AdjustAttachment { relation, end } = self.tool
&& let Some(route) = scene.routed.edges.get(&relation)
&& let Some(tip) = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}
{
frame.stroke(
&Path::circle(point(*tip), 9. / self.scale()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2. / self.scale()),
);
}
if let Some(at) = world
&& self.editable
{
@@ -2483,6 +2644,242 @@ mod tests {
);
}
#[test]
fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
for zoom in [0.5, 1., 2.] {
let mut f = fixture();
let relation =
f.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
let second = state_edit::create_view(&f.project, f.machine).unwrap();
let other_id = second.diagram;
f.project = prepare_command(&f.project, &second.command)
.unwrap()
.project;
let other = f.project.diagrams[&other_id].clone();
let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let route = &scene.routed.edges[&relation];
let tip = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}
.unwrap();
let handle = screen(&canvas, point(*tip));
motion(&canvas, &mut state, handle, handle);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
handle
)
.is_none()
);
assert!(
state.attachment_preview.is_none(),
"A click must not materialize an automatic endpoint"
);
let owner =
state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap();
let destination = screen(
&canvas,
Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y),
);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
motion(&canvas, &mut state, destination, destination);
assert!(state.attachment_preview.is_some());
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
destination,
)
.unwrap();
assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end));
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut document =
Document::from_project(f.project.clone(), Default::default()).unwrap();
let source = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project().relations, f.project.relations);
assert_eq!(document.project().diagrams[&other_id], other);
let route = &document.project().diagrams[&f.diagram].routes[0];
assert_eq!(
route.attachment(end),
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.bundle(), &source);
assert!(!document.undo());
assert!(document.redo());
assert_eq!(document.bundle(), &edited);
}
}
}
#[test]
fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() {
for original in [
None,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut f = fixture();
let relation =
f.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
if original.is_some() {
let mut route =
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation);
route.set_attachment(end, original);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(route);
}
let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.);
let mut state = State::default();
let before = canvas.scene(&state).unwrap();
let edge = &before.routed.edges[&relation];
let tip = match end {
EdgeEnd::Source => edge.points.first(),
EdgeEnd::Target => edge.points.last(),
}
.unwrap();
let start = screen(&canvas, point(*tip));
let away = start + Vector::new(80., 40.);
motion(&canvas, &mut state, start, start);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start,
);
motion(&canvas, &mut state, away, away);
assert!(state.attachment_preview.is_some());
motion(&canvas, &mut state, start, start);
assert!(state.attachment_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert_eq!(
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation)
.attachment(end),
original
);
}
}
}
#[test]
fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() {
let mut f = fixture();
let relation = f
.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
for cancel in 0..9 {
let mut canvas = canvas(
&f,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
1.,
);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0]));
let destination = handle + Vector::new(40., 30.);
motion(&canvas, &mut state, handle, handle);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
motion(&canvas, &mut state, destination, destination);
assert!(state.attachment_preview.is_some());
match cancel {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.tool = Tool::Select,
4 => canvas.editable = false,
5 | 6 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancel == 5 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
destination,
);
}
7 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(destination),
);
}
_ => {
motion(&canvas, &mut state, Point::new(-100., -100.), destination);
}
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
destination
)
.is_none()
);
assert!(state.attachment_preview.is_none());
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() {
let mut f = fixture();
+137 -1
View File
@@ -20,6 +20,7 @@ pub enum Tool {
Region,
Transition,
Reconnect { relation: Id, endpoint: Endpoint },
AdjustAttachment { relation: Id, end: EdgeEnd },
}
#[derive(Debug, Clone)]
@@ -66,6 +67,13 @@ pub enum Event {
endpoint: Endpoint,
vertex: Id,
},
SetAttachment {
context: Context,
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: Option<EdgeAttachment>,
},
}
impl Event {
@@ -84,7 +92,8 @@ impl Event {
| Self::Place { context: c, .. }
| Self::Move { context: c, .. }
| Self::Connect { context: c, .. }
| Self::Reconnect { context: c, .. } => Some(*c),
| Self::Reconnect { context: c, .. }
| Self::SetAttachment { context: c, .. } => Some(*c),
}
}
}
@@ -249,6 +258,36 @@ pub fn can_reconnect(
.is_ok()
}
/// Only the actual visible transition owner can receive a visual attachment.
/// Folded promotion, semantic ports and internal-transition rows are excluded.
pub fn attachment_node(
project: &Project,
diagram: Id,
relation: Id,
end: EdgeEnd,
) -> Option<projection::ProjectedNode> {
machine(project, diagram).ok()?;
let relation_record = project.relations.get(&relation)?;
if relation_record.kind() != RelationKind::StateTransition
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
{
return None;
}
let view = projection::project_view(project, diagram);
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
let stored = project.diagrams.get(&diagram)?;
if projection::edge_endpoint_placements(project, stored, relation)?
!= (edge.source, edge.target)
{
return None;
}
let owner = match end {
EdgeEnd::Source => edge.source,
EdgeEnd::Target => edge.target,
};
view.nodes.iter().find(|node| node.id == owner).cloned()
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -492,6 +531,22 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
relation,
None,
),
Event::SetAttachment {
relation,
end,
placement,
attachment,
..
} => {
attachment_node(project, diagram, relation, end)
.filter(|owner| owner.id == placement)
.ok_or_else(|| {
error("Choose the actual visible transition endpoint in this view.")
})?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
route.set_attachment(end, attachment);
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -580,6 +635,85 @@ mod tests {
plan.diagram
}
#[test]
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
let mut f = fixture();
let diagram = create(&mut f);
let c = context(diagram);
let edge = f.project.relate(
RelationKind::StateTransition,
"Leave work",
f.leaf,
f.target,
);
let attachment = EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.3,
};
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
route.source_attachment = Some(attachment);
route.target_attachment = Some(attachment);
route.waypoints.push(Position { x: 700., y: 700. });
// Retain the exact label value alongside the other route components.
route.label = Some(EdgeLabel {
fraction: 0.5,
offset: Position { x: 10., y: 20. },
});
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route.clone());
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
let event = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: owner.id,
attachment: None,
};
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
.unwrap()
.project;
route.source_attachment = None;
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
assert_eq!(accepted.relations, f.project.relations);
let wrong = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: node(&f.project, diagram, f.target),
attachment: Some(attachment),
};
assert!(plan(&f.project, c, &wrong).is_err());
let composite = node(&f.project, diagram, f.composite);
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&composite)
.unwrap()
.collapsed = true;
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
assert!(plan(&f.project, c, &event).is_err());
let internal = f.project.relate(
RelationKind::StateTransition,
"Internal",
f.target,
f.target,
);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
}
#[test]
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
let f = fixture();
@@ -763,6 +897,8 @@ mod tests {
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pin],
label: None,
});
@@ -178,6 +178,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
visible,
));
}
for (caption, reset, end) in [
(
"Adjust source attachment",
"Reset source attachment",
EdgeEnd::Source,
),
(
"Adjust target attachment",
"Reset target attachment",
EdgeEnd::Target,
),
] {
let owner = state_edit::attachment_node(project, context.diagram, id, end);
entries.push(item(
caption,
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
owner.is_some(),
));
if let Some(owner) = owner {
let authored = project.diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == id)
.and_then(|route| route.attachment(end))
.is_some();
entries.push(item(
reset,
diagram_event(Event::SetAttachment {
context,
relation: id,
end,
placement: owner.id,
attachment: None,
}),
authored,
));
}
}
if !visible {
entries.push(Entry::title(
"Use transition details for hidden or internal endpoints",
+301
View File
@@ -0,0 +1,301 @@
//! 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;
@@ -0,0 +1,626 @@
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));
}
}
+160 -2
View File
@@ -22,6 +22,9 @@ use std::{
sync::Arc,
};
#[cfg(test)]
#[path = "diagram/attachment_tests.rs"]
mod attachment_tests;
#[cfg(test)]
#[path = "diagram/lifecycle_tests.rs"]
mod lifecycle_tests;
#[cfg(test)]
@@ -53,6 +56,7 @@ pub struct DiagramCanvas<'a> {
pub pan: Vector,
pub context: Context,
pub selected_bend: Option<usize>,
pub selected_attachment: Option<EdgeEnd>,
pub editable: bool,
pub placing: bool,
pub connection: Option<&'a crate::connection_edit::Session>,
@@ -67,6 +71,7 @@ pub struct State {
pan_start: Option<(mouse::Button, Point)>,
context: Option<Context>,
route_drag: Option<Gesture>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
connection_press: Option<(Id, Point)>,
connection_hover: Option<Point>,
port_drag: Option<crate::port_edit::Gesture>,
@@ -294,6 +299,11 @@ impl DiagramCanvas<'_> {
);
request.canvas = Some(self.context);
request.creation_point = Some(position(world));
if self.attachment_at(&scene, world).is_some() {
request.object = self.selected;
request.route_point = Some(position(world));
return request;
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
&& let Some(index) = edge
@@ -371,6 +381,39 @@ impl DiagramCanvas<'_> {
})
.map(|(id, segment, _)| (id, segment))
}
fn attachment_at<'b>(
&self,
scene: &'b Scene,
world: Point,
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
[
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
]
.into_iter()
.filter_map(|(end, at)| {
let distance = world.distance(point(*at?));
let owner = route_edit::attachment_node(
self.project,
&self.project.diagrams[&self.diagram],
&scene.view,
self.selected,
end,
)?;
(distance <= 8. / self.zoom).then_some((end, owner, distance))
})
.min_by(|a, b| {
a.2.total_cmp(&b.2).then_with(|| {
(Some(a.0) != self.selected_attachment)
.cmp(&(Some(b.0) != self.selected_attachment))
})
})
.map(|(end, owner, _)| (end, owner))
}
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
@@ -561,6 +604,24 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
// An attachment belongs to the boundary geometry captured at press.
// Camera/layout changes must not reinterpret its pointer in another space.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|| matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
))
{
state.route_drag = None;
state.attachment_viewport = None;
state.pointer = None;
return Some(Action::request_redraw());
}
// A completed work click retires App's allocation epoch before the
// retained canvas sees a redraw. Its already-idle press must not eat
// the first Delete. A live or not-yet-dispatched press stays guarded.
@@ -840,6 +901,21 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
&self.project.diagrams[&self.diagram],
self.selected,
);
if let Some((end, owner)) = self.attachment_at(&scene, world) {
let original = route.attachment(end);
self.begin_route(
state,
world,
Handle::Attachment {
end,
owner: owner.clone(),
original,
},
route,
);
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
return Some(Action::capture());
}
if let Some(index) = edge
.pins
.iter()
@@ -963,7 +1039,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::request_redraw());
}
if let Some(gesture) = &mut state.route_drag {
gesture.move_to(self.world(p), self.zoom);
gesture.move_attachment_to(self.world(p), self.zoom);
return Some(Action::request_redraw().and_capture());
}
if let Some((_, start, original)) = state.drag {
@@ -1040,17 +1116,43 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::capture());
}
if let Some(mut gesture) = state.route_drag.take() {
if matches!(gesture.handle, Handle::Attachment { .. }) {
state.attachment_viewport = None;
if !cursor.is_over(bounds) {
return Some(Action::request_redraw().and_capture());
}
}
// `local` uses the ordered pointer position at release,
// before any later motion in the same native event batch.
if let Some(p) = local {
gesture.move_to(self.world(p), self.zoom);
gesture.move_attachment_to(self.world(p), self.zoom);
}
if !gesture.changed {
return Some(Action::request_redraw().and_capture());
}
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
if gesture.route
== route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
)
{
return Some(Action::request_redraw().and_capture());
}
return Some(
Action::publish(Message::Route(RouteEvent::CommitAttachment(
gesture.context,
gesture.route,
*end,
owner.id,
)))
.and_capture(),
);
}
let bend = match gesture.handle {
Handle::Bend(index) => Some(index),
Handle::Label { .. } => None,
Handle::Attachment { .. } => unreachable!(),
};
return Some(
Action::publish(Message::Route(RouteEvent::Commit(
@@ -1094,6 +1196,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
if cursor.is_over(bounds) =>
{
// Cancel before publishing Zoom: a release may arrive in this
// same event batch, before App applies the new camera.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) {
state.route_drag = None;
state.attachment_viewport = None;
}
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
*y
@@ -1336,6 +1447,41 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
{
for (end, at) in [
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
] {
if let Some(at) = at
&& route_edit::attachment_node(
project,
&project.diagrams[&self.diagram],
view,
self.selected,
end,
)
.is_some()
{
let at = point(*at);
let handle = Path::rectangle(
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
Size::new(11. / self.zoom, 11. / self.zoom),
);
f.fill(
&handle,
if self.selected_attachment == Some(end) {
style::TEAL
} else {
Color::WHITE
},
);
f.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.7 / self.zoom),
);
}
}
for (index, pin) in edge.pins.iter().enumerate() {
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
f.fill(
@@ -2042,6 +2188,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2118,6 +2265,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2243,6 +2391,7 @@ mod tests {
selected: p.id,
navigation_focus: None,
selected_bend: None,
selected_attachment: None,
zoom,
pan: Vector::new(20., 30.),
editable: true,
@@ -2380,6 +2529,8 @@ mod tests {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -2422,6 +2573,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2499,6 +2651,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2597,6 +2750,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2726,6 +2880,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2781,6 +2936,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2856,6 +3012,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2890,6 +3047,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -0,0 +1,446 @@
use super::*;
use crate::app::{App, Page};
use iced::widget::canvas::Program;
fn fixture() -> App {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 100., 150.);
let b = p.add(Kind::Activity, "Receive", 650., 150.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![Position { x: 450., y: 350. }],
label: Some(EdgeLabel {
fraction: 0.4,
offset: Position { x: 5., y: 10. },
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.active_diagram = Some(diagram);
let _ = app.update(Message::Select(edge));
app
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
allocation: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: Vector::new(40., 50.),
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: app
.selected_attachment
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
.map(|(_, _, end)| end),
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
Point::new(
bounds().x + p.x * c.zoom + c.pan.x,
bounds().y + p.y * c.zoom + c.pan.y,
)
}
fn send(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
c.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
let scene = c.scene(&State::default());
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
let node = route_edit::attachment_node(
c.project,
&c.project.diagrams[&c.diagram],
&scene.view,
c.selected,
EdgeEnd::Source,
)
.unwrap();
let end = oa_core::routing::attachment_point(
c.project,
&c.project.diagrams[&c.diagram],
node,
&EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
},
);
(screen(c, start), screen(c, end))
}
fn drag(app: &App, zoom: f32) -> Message {
let c = canvas(app, zoom);
let (start, end) = positions(&c);
let mut state = State::default();
let messages = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| send(&c, &mut state, e, end))
.collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
messages.into_iter().next().unwrap()
}
#[test]
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
for zoom in [0.4, 1., 2.] {
let mut app = fixture();
let before = app.document.bundle().clone();
let project = app.project().clone();
let revision = app.document.revision();
let message = drag(&app, zoom);
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
message
else {
panic!("attachment commit required")
};
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let intended = route.clone();
let _ = app.update(message);
assert_eq!(app.document.revision(), revision + 1);
let mut expected = project;
route_edit::with_route(
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap(),
intended,
);
assert_eq!(
*app.project(),
expected,
"only the intended view attachment changes"
);
let after = app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after, Default::default())
.unwrap()
.decoded
.project,
expected
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
let mut app = fixture();
let before = app.document.bundle().clone();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
assert!(send(&c, &mut state, event, start).is_none());
}
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let _ = app.update(Message::Escape);
assert!(
send(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
for cancel in 0..8 {
let app = fixture();
let before = app.document.bundle().clone();
let mut c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
let mut current_bounds = bounds();
let mut release = end;
match cancel {
0 => c.zoom = 2.,
1 => c.pan.x += 20.,
2 => current_bounds.x += 20.,
3 => current_bounds.height -= 100.,
4..=6 => {
let event = match cancel {
4 => canvas::Event::Window(iced::window::Event::Unfocused),
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
};
let message = c
.update(
&mut state,
&event,
current_bounds,
mouse::Cursor::Available(end),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none());
}
_ => {
release = Point::new(-100., -100.);
assert!(
send(
&c,
&mut state,
mouse::Event::CursorMoved { position: release },
release
)
.is_none()
);
}
}
let message = c
.update(
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
current_bounds,
mouse::Cursor::Available(release),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none(), "cancellation case {cancel}");
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
for wheel in [true, false] {
let mut app = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let camera_message = send(
&c,
&mut state,
if wheel {
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}
} else {
mouse::Event::ButtonPressed(mouse::Button::Middle)
},
end,
);
assert_eq!(camera_message.is_some(), wheel);
assert!(state.route_drag.is_none());
// Deliberately retain the same canvas and defer App camera dispatch.
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
if !wheel {
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
end
)
.is_none()
);
}
if let Some(message) = camera_message {
assert!(matches!(message, Message::Zoom(_)));
let _ = app.update(message);
}
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
let mut app = fixture();
let original = app.active_diagram.unwrap();
let _ = app.update(Message::DuplicateDiagram(original));
let other = app.active_diagram.unwrap();
assert_ne!(original, other);
app.active_diagram = Some(original);
let edge = app.project().diagrams[&original].routes[0].edge;
let _ = app.update(Message::Select(edge));
let untouched = app.project().diagrams[&other].clone();
let c = canvas(&app, 0.7);
let (start, end) = positions(&c);
let mut state = State::default();
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start
)
.is_none()
);
let message = send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.expect("release completes drag without final move");
let _ = app.update(message);
assert_eq!(
app.project().diagrams[&original].routes[0].source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
assert_eq!(app.project().diagrams[&other], untouched);
}
#[test]
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
let mut app = fixture();
let before = app.document.bundle().clone();
let message = drag(&app, 1.);
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
panic!()
};
let mut changed = route.clone();
changed.waypoints.clear();
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, changed, end, node,
)));
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context,
route.clone(),
end,
Id::new_v4(),
)));
let _ = app.update(Message::Escape);
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, route, end, node,
)));
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
let mut app = fixture();
let before = app.project().clone();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
let mut expected = before;
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap()
.routes[0]
.target_attachment = None;
assert_eq!(*app.project(), expected);
let revision = app.document.revision();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
assert_eq!(app.document.revision(), revision);
}
#[test]
fn coincident_attachment_handles_honor_explicit_end_choice() {
let mut app = fixture();
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
EdgeEnd::Target,
)));
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
&c.project.diagrams[&c.diagram],
&projection::project_view(c.project, c.diagram),
)),
};
let routed = Arc::make_mut(&mut scene.routed);
let edge = routed.edges.get_mut(&c.selected).unwrap();
let start = *edge.points.first().unwrap();
*edge.points.last_mut().unwrap() = start;
assert_eq!(
c.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Target
);
let mut source = c;
source.selected_attachment = Some(EdgeEnd::Source);
assert_eq!(
source.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Source
);
}
+5 -1
View File
@@ -78,7 +78,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
}
if state.context.is_none_or(|previous| {
(previous.document, previous.diagram, previous.epoch)
!= (self.context.document, self.context.diagram, self.context.epoch)
!= (
self.context.document,
self.context.diagram,
self.context.epoch,
)
}) || !self.enabled
{
state.focused = false;
@@ -62,6 +62,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -70,6 +70,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -16,6 +16,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -186,6 +186,7 @@ mod tests {
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
+2 -2
View File
@@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article {
&[
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
@@ -564,7 +564,7 @@ fn article(topic: Topic) -> Article {
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"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.",
"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. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
+2
View File
@@ -8,6 +8,7 @@ mod connection_tool;
mod context_menu;
mod data_values;
mod data_view;
mod diagnostic_input;
mod diagram;
mod diagram_usage;
mod document;
@@ -26,6 +27,7 @@ mod port_workflow;
mod recovery;
mod route_edit;
mod semantic_review;
mod source_annotations;
mod source_navigation;
mod source_rename;
mod source_tree;
+57
View File
@@ -240,6 +240,33 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
.is_none_or(|(s, t)| s == e.source && t == e.target)
});
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
for (end, choose, reset) in [
(
EdgeEnd::Source,
"Move source attachment",
"Reset source attachment",
),
(
EdgeEnd::Target,
"Move target attachment",
"Reset target attachment",
),
] {
let eligible = editable
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
entries.push(item(
choose,
Message::Route(route_edit::Event::ChooseAttachment(end)),
eligible,
));
entries.push(item(
reset,
Message::Route(route_edit::Event::ResetAttachment(end)),
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
));
}
}
if let Some(point) = request.route_point {
entries.push(
item(
@@ -900,6 +927,36 @@ mod tests {
assert_eq!(app.document.bundle(), &changed);
}
#[test]
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
let mut project = Project::empty();
let source = project.add(Kind::Activity, "Send", 0., 0.);
let target = project.add(Kind::Activity, "Receive", 650., 0.);
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
let mut app = App::with_project(project);
let before = app.document.bundle().clone();
let mut request = Request::object(edge, Point::ORIGIN);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request.clone())));
let action = choose(&app, "Move target attachment");
send(&mut app, action);
assert_eq!(
app.selected_attachment,
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
);
assert_eq!(app.document.bundle(), &before);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request)));
let stale = choose(&app, "Move source attachment");
send(&mut app, Message::Escape);
send(&mut app, stale);
assert!(
app.selected_attachment
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
let mut p = Project::empty();
+62 -1
View File
@@ -14,6 +14,9 @@ pub struct Context {
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
ChooseAttachment(EdgeEnd),
ResetAttachment(EdgeEnd),
AddBend,
AddBendAt(oa_core::Position),
RemoveBend,
@@ -23,7 +26,14 @@ pub enum Event {
#[derive(Debug, Clone)]
pub enum Handle {
Bend(usize),
Label { original: Position },
Label {
original: Position,
},
Attachment {
end: EdgeEnd,
owner: projection::ProjectedNode,
original: Option<EdgeAttachment>,
},
}
#[derive(Debug, Clone)]
@@ -58,8 +68,57 @@ impl Gesture {
};
}
}
Handle::Attachment { .. } => {}
}
}
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
let Handle::Attachment {
end,
ref owner,
original,
} = self.handle
else {
self.move_to(point, zoom);
return;
};
if point.distance(self.start) * zoom < 3. {
self.route.set_attachment(end, original);
self.changed = false;
return;
}
self.changed = true;
let attachment = attachment_from_point(
owner,
position(point),
self.route.attachment(end).map(|a| a.side),
6. / zoom,
);
self.route.set_attachment(end, Some(attachment));
}
}
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
pub fn attachment_node<'a>(
project: &Project,
diagram: &Diagram,
view: &'a projection::ProjectedView,
edge: Id,
end: EdgeEnd,
) -> Option<&'a projection::ProjectedNode> {
if semantic_port_endpoint(project, edge, end).is_some() {
return None;
}
let projected = view.edges.iter().find(|e| e.id == edge)?;
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
if actual != (projected.source, projected.target) {
return None;
}
let placement = match end {
EdgeEnd::Source => projected.source,
EdgeEnd::Target => projected.target,
};
view.nodes.iter().find(|node| node.id == placement)
}
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
@@ -72,6 +131,8 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
source_attachment: None,
target_attachment: None,
})
}
+312
View File
@@ -0,0 +1,312 @@
//! 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;
#[cfg(all(test, debug_assertions))]
pub(crate) use widget::tests::AppMounted;
@@ -0,0 +1,260 @@
//! 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:#?}"
);
}
@@ -0,0 +1,321 @@
//! 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"
);
}
@@ -0,0 +1,216 @@
//! 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"
);
}
@@ -0,0 +1,83 @@
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);
}
@@ -0,0 +1,425 @@
//! 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(
&paragraph,
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))]
pub(crate) mod tests;
File diff suppressed because it is too large Load Diff
@@ -10,9 +10,9 @@ pub enum SymbolEvent {
DiagramUsages,
Query(query::Action),
Back,
Close,
Choose {
stamp: query::QueryStamp,
visit: Id,
reference: bool,
index: usize,
},
@@ -34,7 +34,7 @@ pub struct HistoryEntry {
pub cursor: Cursor,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
pub job: Id,
pub interaction: Id,
@@ -61,12 +61,15 @@ pub struct Reveal {
}
pub struct SymbolState {
pub open: bool,
pub interaction: Id,
pub raw_interaction: Arc<AtomicU64>,
pub serial: u64,
pub in_flight: Option<Id>,
pub request: Option<Request>,
/// 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>,
@@ -77,12 +80,12 @@ pub struct SymbolState {
impl Default for SymbolState {
fn default() -> Self {
Self {
open: false,
interaction: Id::new_v4(),
raw_interaction: Arc::new(AtomicU64::new(0)),
serial: 0,
in_flight: None,
request: None,
display_request: None,
cache: None,
results: None,
notice: None,
+2
View File
@@ -329,6 +329,8 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: 400. }],
label: Some(EdgeLabel {
fraction: 0.5,
+2
View File
@@ -112,6 +112,8 @@ mod tests {
});
view.routes.push(EdgeRoute {
edge: exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 420., y: 440. }],
label: Some(EdgeLabel {
fraction: 0.4,
+87 -432
View File
@@ -1,4 +1,5 @@
mod source_symbols;
mod text_workspace;
pub(crate) mod traceability;
use crate::{
app::{App, Choice, FileOperation, Message, Page, Pending},
@@ -256,7 +257,7 @@ pub fn view(app: &App) -> View<'_, Message> {
p.relations.len()
)),
action(
&format!("{} checks", app.issue_count()),
&format!("{} model checks", app.issue_count()),
Message::Page(Page::Validation)
)
]
@@ -465,6 +466,12 @@ fn diagram(app: &App) -> View<'_, Message> {
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_attachment: app
.selected_attachment
.filter(|(context, edge, _)| {
Some(*context) == app.route_context() && *edge == app.selected
})
.map(|(_, _, end)| end),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
@@ -1096,239 +1103,79 @@ fn source_files(app: &App) -> View<'_, Message> {
}
fn source(app: &App) -> View<'_, Message> {
use crate::source_navigation::Event as SourceEvent;
let (undo_label, undo_message) = match app.source_rename_undo_message() {
Some(message) => ("Undo rename", Some(message)),
None => (
"Undo model",
(app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo),
),
};
let navigation: View<'_, Message> = if app.source_navigation.open {
column![
row![
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", app.source_find_close_message()),
]
.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 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;
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));
}
visible_diagnostics += 1;
diagnostics = diagnostics.push(
button(
text(format!(
"{}:{}:{} {}",
issue.path,
issue.diagnostic.line,
issue.diagnostic.column,
issue.diagnostic.message
))
.size(12)
.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(Message::SourceDiagnostic(index)),
);
for related in &issue.related {
diagnostics = diagnostics.push(action(
&format!("Related declaration · {}", related.path),
Message::SourceRelated(related.clone()),
));
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,
};
let severity = match issue.diagnostic.severity {
validation::Severity::Error => "Error",
validation::Severity::Warning => "Warning",
};
diagnostics = diagnostics.push(
button(
text(format!(
"{severity} · {}:{}:{} · {}",
issue.path,
issue.diagnostic.line,
issue.diagnostic.column,
issue.diagnostic.message,
))
.size(12)
.wrapping(widget::text::Wrapping::WordOrGlyph),
)
.width(Fill)
.padding([4, 6])
.style(style::nav)
.on_press_maybe(app.text_diagnostic_message(location)),
);
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())),
);
}
}
}
let editor = widget::text_editor(&app.source_editor)
.id(app.source_editor_id())
.style(style::source_editor)
.on_action(Message::SourceEdit)
.key_binding({
let context = app.source_symbol_context();
let rename = app.source_rename_begin();
move |press| source_binding(press, &context, Some(&rename))
})
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill)
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
app.source_snapshot.clone(),
crate::highlight::format,
);
let header = 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_label).size(12)).padding([8,10]).style(style::nav)
.on_press_maybe(undo_message),
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
label(if app.source_dirty() {
"Drafts pending"
} else {
"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,
row![
action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))),
action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))),
action("Rename alias · F2", app.source_rename_message()),
action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)),
button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav)
.on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))),
].spacing(7),
scrollable(diagnostics)
.height((visible_diagnostics as f32 * 32.).min(132.))
]
.spacing(12)
)
.padding(18);
let editor = widget::sensor(editor)
.key((
app.source_workspace.active.clone(),
app.source_reveal_ticket(),
))
.on_show({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
})
.on_resize({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
});
let workspace: View<'_, Message> = if app.source_symbols.open {
row![
container(editor).width(Fill).height(Fill),
container(scrollable(source_symbols::panel(app)))
.width(300)
.height(Fill),
]
.height(Fill)
.into()
} else {
container(editor).width(Fill).height(Fill).into()
};
source_layout(header.into(), workspace, app.window_size.height)
let guard = crate::diagnostic_input::Guard::new(
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
diagnostics,
);
// The complete byte/evidence comparison is shared by the summary and rows,
// once per visible References rebuild; no parser or second query is started.
let references_current =
app.text_workspace.drawer() == Some(DrawerTab::References) && app.source_results_current();
text_workspace::view(
app,
source_symbols::panel(app, references_current),
guard.into(),
references_current,
)
}
fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
header: View<'a, Message, iced::Theme, Renderer>,
workspace: View<'a, Message, iced::Theme, Renderer>,
window_height: f32,
) -> View<'a, Message, iced::Theme, Renderer> {
// Lint clears and repopulates diagnostics asynchronously. Reserve the same
// outer slot so a pointer aimed at the editor cannot land in the header.
column![
container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)),
workspace,
]
.height(Fill)
.into()
}
pub(crate) fn source_binding(
press: widget::text_editor::KeyPress,
context: &crate::source_navigation::SymbolContext,
@@ -1392,8 +1239,7 @@ pub(crate) mod source_tests {
use iced::advanced::{
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
};
use iced::widget::column;
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
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;
@@ -1438,207 +1284,6 @@ pub(crate) mod source_tests {
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
#[derive(Default)]
struct Geometry {
header: Option<(Rectangle, Rectangle, Vector)>,
editor: Option<Rectangle>,
}
impl core_widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) {
if id == Some(&core_widget::Id::new("test-source-editor")) {
self.editor = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&core_widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn core_widget::operation::Scrollable,
) {
if id == Some(&core_widget::Id::new("source-header")) {
self.header = Some((bounds, content, offset));
}
}
}
fn mounted_source<'a>(
content: &'a widget::text_editor::Content<FontLayout>,
diagnostics: usize,
find: bool,
window_height: f32,
) -> View<'a, Message, iced::Theme, FontLayout> {
let mut notices = column![].spacing(5);
for index in 0..diagnostics {
notices = notices.push(button(text(format!(
"救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1,
)).size(12)).padding([4, 6]).width(Fill));
}
let find: View<'_, Message, iced::Theme, FontLayout> = if find {
column![
text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Query(s)
)),
text_input("Line", "1").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Line(s)
)),
]
.spacing(8)
.into()
} else {
Space::new().into()
};
let header = container(
column![
text("Textual model").size(24),
text("Edit the current file. Apply text validates the complete included model.")
.size(11),
row![
button(text("Apply text")),
button(text("Undo text")),
button(text("Model Undo"))
]
.spacing(8),
text("救援/assess.arcadia · All files applied to the accepted model").size(11),
text("Undo text affects this draft. Model Undo restores accepted files together.")
.size(11),
find,
row![
button(text("Definition · F12")),
button(text("References · Shift F12")),
button(text("Back"))
]
.spacing(7),
scrollable(notices).height((diagnostics as f32 * 32.).min(132.)),
]
.spacing(12),
)
.padding(18);
let editor = container(
widget::text_editor(content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill),
)
.id("test-source-editor")
.height(Fill)
.width(Fill);
source_layout(header.into(), editor.into(), window_height)
}
#[test]
fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() {
for (host, window_height) in [
(Size::new(720., 540.), 700.),
(Size::new(1180., 890.), 1080.),
] {
let content = widget::text_editor::Content::<FontLayout>::with_text(
"// is excluded\r\n// 雨\r\n",
);
let mut tree = core_widget::Tree::empty();
let mut baseline = None;
for (diagnostics, find) in [
(0, false),
(6, false),
(0, false),
(6, true),
(1, true),
(0, false),
] {
let mut ui = mounted_source(&content, diagnostics, find, window_height);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&FontLayout,
&mut geometry,
);
let editor = geometry.editor.unwrap();
let (header, _, _) = geometry.header.unwrap();
let slot = node.children()[0].bounds();
assert_eq!(slot.height, (window_height * 0.42).max(180.));
assert!(
header.height <= slot.height,
"short content may shrink inside the fixed slot"
);
assert!(
editor.height >= 240.,
"remaining editor must stay usable: {editor:?}"
);
assert_eq!(editor.y, slot.y + slot.height);
if let Some(expected) = baseline {
assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}");
} else {
baseline = Some(editor);
}
}
}
}
#[test]
fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() {
let content = widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n");
let host = Size::new(720., 540.);
let mut tree = core_widget::Tree::empty();
let mut saved = None;
for diagnostics in [6, 7] {
let mut ui = mounted_source(&content, diagnostics, true, 700.);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let (bounds, contents, _) = geometry.header.unwrap();
assert!(
contents.height > bounds.height,
"fixture must actually overflow"
);
if saved.is_none() {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
ui.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. },
}),
Layout::new(&node),
iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)),
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(host),
);
assert!(shell.is_event_captured());
}
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let offset = geometry.header.unwrap().2;
assert!(offset.y > 0., "the actual mounted Scrollable must move");
if let Some(before) = saved {
assert_eq!(offset, before);
} else {
saved = Some(offset);
}
}
}
#[cfg(target_os = "windows")]
#[test]
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
@@ -1892,6 +1537,16 @@ fn checks(app: &App) -> View<'_, Message> {
scrollable(out.padding(24)).into()
}
#[cfg(all(test, debug_assertions))]
pub(crate) fn captured_text_drawer_resize(app: &App) -> Message {
text_workspace::capture_drawer_resize(app)
}
#[cfg(all(test, debug_assertions))]
pub(crate) fn captured_source_reference_stale_summary(app: &App) -> bool {
text_workspace::summary_visibility_tests::rendered_stale(app)
}
#[cfg(test)]
mod tests {
use super::short;
+278 -153
View File
@@ -2,80 +2,151 @@ use crate::{
app::{App, Message},
source_navigation::{
SymbolEvent,
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
query::{Declaration, Disposition, Evidence, Identity, Membership, Position},
},
style,
};
use iced::{
Element, Fill,
widget::{Space, button, column, container, row, scrollable, text, tooltip},
widget::{button, column, container, scrollable, text, tooltip},
};
pub(super) fn panel(app: &App) -> Element<'_, Message> {
pub(super) fn panel(app: &App, current: bool) -> Element<'_, Message> {
use crate::app::text_workspace::{DrawerTab, Event};
use std::collections::BTreeSet;
let state = &app.source_symbols;
if !state.open
|| (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none())
{
return column![].into();
let 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));
}
let title = state.results.as_ref().map_or_else(
|| "Source symbols".to_owned(),
|results| {
let action = match results.action {
query::Action::Definition => "Definitions",
query::Action::References => "References",
};
results.alias.as_ref().map_or_else(
|| action.to_owned(),
|alias| format!("{action} · @{}", preview(alias, 40)),
)
},
);
let mut content = column![
row![
text(title).size(13),
Space::new().width(Fill),
button(text("Close").size(11))
.padding([3, 8])
if visible && let Some(results) = &state.results {
let stale = !current;
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(app.source_symbol_message(SymbolEvent::Close)),
]
.align_y(iced::Center)
]
.spacing(5);
if let Some(notice) = &state.notice {
content = content.push(super::label(preview(notice, 240)));
}
if state.in_flight.is_some() {
content = content.push(super::label("Finding source symbols…"));
} else if let Some(results) = &state.results {
let stale = results.stamp.snapshot != app.source_key();
if stale {
content = content.push(super::label(
"Stale results — source changed. Run Definition or References again.",
));
.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;
content = content.push(super::label(format!(
"{} · {} · {} included / {} detached / {} unverified files · {} scope issues",
if scope.partial() {
"Partial scope"
} else {
"Complete include scope"
},
if !scope.applied {
"Unapplied drafts"
} else if scope.validated {
"Validated sources"
} else {
"Draft sources"
},
scope.included_files,
scope.detached_files,
scope.unverified_files,
scope.issues.len(),
)));
let explanation = match results.disposition {
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
@@ -87,89 +158,113 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> {
"Lexical uses of this alias; these do not establish a semantic target."
}
};
content = content.push(super::label(explanation));
if results.total_definitions > 1 {
content = content.push(super::label(format!(
"Alias collision: {} declarations. Uses are not attributed to a single declaration.",
results.total_definitions,
)));
}
let enabled = !stale && !app.busy;
let mut rows = column![super::label(counts(
"Declarations",
results.definitions.len(),
results.total_definitions,
results.omitted_definitions,
))]
.spacing(4);
for (index, definition) in results.definitions.iter().enumerate() {
rows = rows.push(location_row(
declaration_title(&definition.declaration),
&definition.path,
definition.position,
provenance(
definition.membership,
definition.evidence,
definition.declaration.identity,
false,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
})
}),
));
}
rows = rows.push(super::label(counts(
"Lexical uses",
results.references.len(),
results.total_references,
results.omitted_references,
)));
for (index, reference) in results.references.iter().enumerate() {
let title = reference.context.as_ref().map_or_else(
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|context| {
format!(
"@{} · inside {}",
preview(&reference.alias, 48),
declaration_title(context)
)
},
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),
);
rows = rows.push(location_row(
title,
&reference.path,
reference.position,
provenance(
reference.membership,
reference.evidence,
reference
.context
.as_ref()
.map_or(Identity::Missing, |context| context.identity),
true,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: true,
index,
})
}),
));
}
let row_count = results.definitions.len() + results.references.len();
content =
content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0)));
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),
);
}
container(content)
.padding(8)
.width(Fill)
.style(style::subtle)
.into()
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 {
@@ -223,23 +318,24 @@ fn location_row(
) -> Element<'static, Message> {
let line = position.line.saturating_add(1);
let column = position.byte_column.saturating_add(1);
let location = format!("{}:{line}:{column}", preview(path, 72));
let full =
format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)");
tooltip(
button(
column![
text(title).size(12),
super::label(format!("{location} · {status}"))
]
.spacing(2),
container(
text(format!("{line}:{column} {title} · {status}"))
.size(12)
.wrapping(iced::widget::text::Wrapping::None),
)
.clip(true)
.width(Fill),
)
.padding([4, 6])
.padding([3, 12])
.height(28)
.width(Fill)
.style(style::nav)
.on_press_maybe(event),
text(format!(
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
))
.size(11),
text(full).size(12),
tooltip::Position::Top,
)
.into()
@@ -258,3 +354,32 @@ fn preview(value: &str, limit: usize) -> String {
}
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
@@ -0,0 +1,262 @@
//! 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,
flags: &[&str],
) -> 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, app.source_results_current()),
width,
&FLAGS,
);
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, &FLAGS);
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., &FLAGS);
assert!(
!rejected.is_empty(),
"negative control must detect missing/clipped warning text"
);
}
pub(crate) fn rendered_stale(app: &App) -> bool {
draw_and_check_flags(
reference_summary(app, app.source_results_current()),
720.,
&["Stale"],
)
.is_empty()
}
@@ -234,6 +234,8 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
.routes
.push(EdgeRoute {
edge: trace,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 301., y: 507. }],
label: Some(EdgeLabel {
fraction: 0.35,
+2
View File
@@ -12,6 +12,8 @@ use tokio::{sync::oneshot, task::JoinHandle};
mod communication_limits;
#[path = "http/data_contracts.rs"]
mod data_contracts;
#[path = "http/edge_attachments.rs"]
mod edge_attachments;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/semantic_transitions.rs"]
@@ -0,0 +1,344 @@
use super::*;
use oa_core::{
EdgeAttachment, PortAnchorSide,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
};
use std::collections::BTreeMap;
fn source_bundle() -> SourceBundle {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
]),
}, ProjectLimits::default()).unwrap().bundle
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
async fn import(server: &TestServer) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&source_bundle())
.send()
.await
.unwrap();
let status = response.status();
let value: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{value}");
server
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: Value,
dry_run: bool,
) -> reqwest::Response {
server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap()
}
fn assert_pair(current: &StoredProject) {
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(compiled.bundle, current.source_project);
}
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
let actual = server.get(expected.resource_id).await;
assert_eq!(actual.etag(), expected.etag());
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(expected).unwrap()
);
}
#[tokio::test]
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server).await;
let diagram = id(&original.model, "view");
let mut expected = original.model.clone();
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25,
});
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0,
});
let commands = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
let preview = transact(&server, &original, commands.clone(), true).await;
assert_eq!(preview.status(), StatusCode::OK);
let preview: Value = preview.json().await.unwrap();
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
assert_eq!(
compile_bundle(&proposed, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
assert_unchanged(&server, &original).await;
let applied = transact(&server, &original, commands.clone(), false).await;
assert_eq!(applied.status(), StatusCode::OK);
let mut current = server.get(original.resource_id).await;
assert_eq!(applied.headers()["etag"], current.etag());
assert_eq!(current.revision, original.revision + 1);
assert_eq!(
current.model, expected,
"only the two attachment fields in this view change"
);
assert_eq!(current.source_project, proposed);
assert_pair(&current);
for path in ["project.arcadia", "model.arcadia"] {
assert_eq!(
current.source_project.files[path],
original.source_project.files[path]
);
}
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
let noop = transact(&server, &current, commands.clone(), false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], current.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
let stale = json!([
Command::Rename(id(&current.model, "first"), "Must not persist".into()),
commands[0].clone()
]);
assert_eq!(
transact(&server, &original, stale, false).await.status(),
StatusCode::PRECONDITION_FAILED
);
assert_unchanged(&server, &current).await;
let mut edited = current.source_project.clone();
let text = edited.files.get_mut("views.arcadia").unwrap();
assert_eq!(text.matches("offset 0.25;").count(), 1);
*text = text.replace("offset 0.25;", "offset 0.375;");
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
current = server.get(current.resource_id).await;
assert_eq!(current.model, expected);
assert_eq!(current.source_project, edited);
assert_pair(&current);
let source = server
.client
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(source.status(), StatusCode::OK);
assert_eq!(source.headers()["etag"], current.etag());
assert_eq!(
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
expected
);
let bundle = server
.client
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(bundle.status(), StatusCode::OK);
assert_eq!(bundle.headers()["etag"], current.etag());
assert_eq!(
bundle.json::<SourceBundle>().await.unwrap(),
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_unchanged(&server, &current).await;
let mut route = expected.diagrams[&diagram].routes[0].clone();
route.source_attachment = None;
let reset = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
assert_eq!(
transact(&server, &current, reset.clone(), false)
.await
.status(),
StatusCode::OK
);
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
let reset_state = server.get(current.resource_id).await;
assert_eq!(
reset_state.model, expected,
"reset preserves pins, label, opposite end, other view and semantics"
);
assert_pair(&reset_state);
let noop = transact(&server, &reset_state, reset, false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], reset_state.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server).await;
let diagram = id(&current.model, "view");
let first = id(&current.model, "first");
for (bad, status) in [
(json!({}), StatusCode::BAD_REQUEST),
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
(
json!({"side":"Diagonal","offset":0.5}),
StatusCode::BAD_REQUEST,
),
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
(
json!({"side":"Top","offset":"NaN"}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":0.5,"unknown":true}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":-0.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"side":"Top","offset":1.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route =
serde_json::to_value(&current.model.diagrams[&diagram].routes[0]).unwrap();
route[field] = bad.clone();
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
let response = transact(&server, &current, commands, false).await;
assert_eq!(
response.status(),
status,
"{field} {bad}: {}",
response.text().await.unwrap()
);
assert_unchanged(&server, &current).await;
}
}
let mut invalid = current.model.clone();
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 2.0,
});
let response = server
.client
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
.header("if-match", current.etag())
.json(&invalid)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_unchanged(&server, &current).await;
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.get(server.url("/openapi.json"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let schema: Value = response.json().await.unwrap();
let schemas = &schema["components"]["schemas"];
let attachment = &schemas["EdgeAttachment"];
assert_eq!(attachment["required"], json!(["side", "offset"]));
assert_eq!(attachment["additionalProperties"], false);
assert_eq!(
attachment["properties"]["side"]["$ref"],
"#/components/schemas/PortAnchorSide"
);
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
for field in ["source_attachment", "target_attachment"] {
assert!(
!schemas["EdgeRoute"]["required"]
.as_array()
.unwrap()
.contains(&json!(field))
);
let optional = &schemas["EdgeRoute"]["properties"][field];
assert_eq!(optional["default"], Value::Null);
assert_eq!(
optional["oneOf"],
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
);
}
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
let diagram = id(&compiled.decoded.project, "view");
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
let omitted = serde_json::to_value(&route).unwrap();
assert!(omitted.get("source_attachment").is_none());
assert!(omitted.get("target_attachment").is_none());
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.0,
});
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
assert_eq!(
encoded["SetEdgeRoute"]["route"]["target_attachment"],
json!({"side":"Right","offset":0.0})
);
server.stop().await;
}
+158
View File
@@ -75,6 +75,164 @@ fn assert_pair(current: &StoredProject) {
assert_eq!(compiled.bundle, current.source_project);
}
#[tokio::test]
async fn explicit_empty_port_anchors_survive_http_move_and_restart_but_export_compactly() {
fn replace_once(source: &mut String, before: &str, after: &str) {
assert_eq!(
source.matches(before).count(),
1,
"ambiguous or absent anchor: {before}"
);
let replacement = source.replacen(before, after, 1);
assert_ne!(*source, replacement, "fixture mutation must change source");
*source = replacement;
}
async fn assert_exports(server: &TestServer, current: &StoredProject, node: Id) -> String {
let response = server
.client
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["etag"], current.etag());
assert_eq!(
response.json::<SourceBundle>().await.unwrap(),
current.source_project
);
let response = server
.client
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let canonical = response.text().await.unwrap();
assert_eq!(oa_core::language::parse(&canonical).unwrap(), current.model);
assert_eq!(canonical, oa_core::language::format(&current.model));
let exported = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), canonical.clone())]),
},
ProjectLimits::default(),
)
.unwrap();
assert_eq!(exported.decoded.project, current.model);
assert!(exported.index.declarations.contains_key(&node));
assert!(
!exported
.index
.fields
.contains_key(&(node, "port_anchors".into()))
);
canonical
}
let mut authored = source_bundle();
replace_once(
authored.files.get_mut("views.arcadia").unwrap(),
"diagram @architecture; element @vessel;",
"diagram @architecture; element @vessel;\n position { x 12.5; y -4.0; };\n port_anchors [] ; // Keep explicit automatic placement.\n",
);
let compiled = compile_bundle(&authored, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle, authored,
"the HTTP fixture must already be stable"
);
let diagram = id(&compiled.decoded.project, "architecture");
let node = id(&compiled.decoded.project, "actor_view");
assert!(
compiled
.index
.fields
.contains_key(&(node, "port_anchors".into()))
);
assert_eq!(
compiled.decoded.project.diagrams[&diagram].nodes[&node].position,
oa_core::Position { x: 12.5, y: -4.0 }
);
assert!(
compiled.decoded.project.diagrams[&diagram].nodes[&node]
.port_anchors
.is_empty()
);
// Independent full-model oracle: only this placement coordinate changes.
let mut expected_model = compiled.decoded.project.clone();
expected_model
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position
.x = 25.0;
let mut expected_source = authored.clone();
replace_once(
expected_source.files.get_mut("views.arcadia").unwrap(),
"x 12.5;",
"x 25.0;",
);
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&authored)
.send()
.await
.unwrap();
let status = response.status();
let value: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{value}");
let original = server
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
.await;
assert_eq!(original.model, compiled.decoded.project);
assert_eq!(original.source_project, authored);
assert_pair(&original);
assert_exports(&server, &original, node).await;
let response = transact(
&server,
&original,
json!([{"MoveNode": {"diagram": diagram, "node": node,
"position": {"x": 25.0, "y": -4.0}, "with_descendants": false}}]),
false,
)
.await;
let status = response.status();
let value: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
assert_eq!(value["changed"], true);
let current = server.get(original.resource_id).await;
assert_eq!(current.revision, original.revision + 1);
assert_eq!(current.model, expected_model);
// Exact bundle equality covers the explicit empty field, its rationale,
// sibling placement, and all untouched files and source trivia.
assert_eq!(current.source_project, expected_source);
assert_pair(&current);
let canonical = assert_exports(&server, &current, node).await;
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(current.resource_id).await;
assert_eq!(reopened.model, expected_model);
assert_eq!(reopened.source_project, expected_source);
assert_eq!(
serde_json::to_value(&reopened).unwrap(),
serde_json::to_value(&current).unwrap()
);
assert_pair(&reopened);
assert_eq!(assert_exports(&server, &reopened, node).await, canonical);
server.stop().await;
}
#[tokio::test]
async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() {
let directory = tempfile::tempdir().unwrap();
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "54abf81d13fec06d4d9ac754b03ce2c3313e8a1f"
},
"path_in_vcs": "widget"
}
+31
View File
@@ -0,0 +1,31 @@
# 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.
+126
View File
@@ -0,0 +1,126 @@
# 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
View File
@@ -0,0 +1,51 @@
[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
+18
View File
@@ -0,0 +1,18 @@
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
View File
@@ -0,0 +1,352 @@
[
{
"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"
}
]
+2
View File
@@ -0,0 +1,2 @@
<?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>

After

Width:  |  Height:  |  Size: 1.8 KiB

+85
View File
@@ -0,0 +1,85 @@
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
View File
@@ -0,0 +1,752 @@
//! 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
View File
@@ -0,0 +1,327 @@
//! 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
View File
@@ -0,0 +1,111 @@
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
View File
@@ -0,0 +1,707 @@
//! 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;
//! }
//! }
//! }
//! ```
//! ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true)
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;
/// }
/// }
/// }
/// ```
/// ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true)
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
View File
@@ -0,0 +1,617 @@
//! 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)
}
}

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