Compare commits

..
1871 changed files with 24940 additions and 397597 deletions
+17 -34
View File
@@ -71,37 +71,20 @@ harness/history/2026-09-11-state-before-gitea-migration/** -text whitespace=cr-a
harness/experiments/contextual-prerelease-20260911/native/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-prerelease-20260911/producer/package*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-prerelease-20260911/producer/source-push.log -whitespace
harness/experiments/source-alias-rename-native-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-completed-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-refresh-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-native-closure-20260912/** -text whitespace=cr-at-eol
# Preserve Text-workspace input, native and review evidence at its recorded bytes.
harness/experiments/text-workspace-initial-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-initial-review-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-find-selection-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-native2-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-followup-review-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-native3-20260913/** -text whitespace=cr-at-eol
# These checksum-bound baseline/expected fixtures deliberately end with blank lines.
harness/experiments/text-workspace-native2-20260913/fixtures/**/*.arcadia whitespace=-blank-at-eof,cr-at-eol
# Captured process streams retain intentional blank lines and diagnostic spacing.
harness/experiments/text-find-selection-20260913/**/*.log -whitespace
harness/experiments/text-workspace-initial-20260913/**/*.log -whitespace
harness/experiments/text-workspace-native2-20260913/**/*.log -whitespace
harness/experiments/text-workspace-native3-20260913/**/*.log -whitespace
# Preserve the pinned dependency and diagnostic verification records verbatim.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
harness/experiments/text-diagnostics-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-diagnostics-20260913/**/*.log -whitespace
# Preserve default-omission failure evidence and captured input manifests verbatim.
harness/experiments/default-omission-20260913/** -text whitespace=cr-at-eol
harness/experiments/default-omission-20260913/**/*.log -whitespace
harness/experiments/default-admission-20260913/** -text whitespace=cr-at-eol
harness/experiments/default-admission-20260913/**/*.log -whitespace
harness/experiments/default-policy-20260913/** -text whitespace=cr-at-eol
harness/experiments/default-policy-20260913/**/*.log -whitespace
harness/experiments/default-fidelity-20260913/** -text whitespace=cr-at-eol
harness/experiments/default-fidelity-20260913/**/*.log -whitespace
harness/experiments/default-production-20260913/** -text whitespace=cr-at-eol
harness/experiments/default-followup-20260913/** -text whitespace=cr-at-eol
harness/experiments/default-consumers-20260913/** -text whitespace=cr-at-eol
# Exact frozen navigation GUI input bytes; historical server source is separate.
crates/oa-desktop/src/behavior/creation.rs -text
crates/oa-desktop/src/behavior/scenario/workspace.rs -text
crates/oa-desktop/src/behavior/topology/contextual.rs -text
crates/oa-desktop/src/guide.rs -text
crates/oa-desktop/src/semantic_review/artifacts_view.rs -text
crates/oa-desktop/src/semantic_review/attachments_view.rs -text
crates/oa-desktop/src/semantic_review/keyed.rs -text
crates/oa-desktop/src/semantic_review/keyed_rows.rs -text
crates/oa-desktop/src/semantic_review/keyed_tests.rs -text
crates/oa-desktop/src/semantic_review/ordered.rs -text
docs/api.html -text
docs/api.rst -text
docs/language.html -text
docs/openapi.json -text
harness/fixtures/rest-coverage/coverage-probe.json -text
Generated
+2 -1
View File
@@ -1567,6 +1567,8 @@ dependencies = [
[[package]]
name = "iced_widget"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab"
dependencies = [
"iced_renderer",
"log",
@@ -2174,7 +2176,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
-4
View File
@@ -1,11 +1,7 @@
[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();
// Keep discovery/IO failures that lint cannot recover from the bundle alone.
// Recompute source diagnostics below, against the same original bytes.
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
+15 -49
View File
@@ -3,8 +3,6 @@
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};
@@ -647,34 +645,25 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
source.len()
}
/// 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.
/// Canonical output uses persistent aliases and explicit identities. 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",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
quote(&project.description)
);
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) {
for (_, declaration) in format_declarations(project) {
out.push_str(&declaration);
}
out.push_str("}\n");
out
}
/// 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)> {
/// The same typed declaration writer is used by flat and modular source output.
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let aliases = project
.identities()
.into_iter()
@@ -686,7 +675,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
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()),
@@ -702,7 +690,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
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()),
@@ -718,7 +705,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
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",
@@ -733,7 +719,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
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));
}
@@ -746,7 +731,7 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
&a.alias,
Some(&a.name),
a.id,
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
&record_fields(a),
&aliases,
);
declarations.push((a.id, out));
@@ -759,7 +744,7 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
&t.alias,
Some(&t.name),
t.id,
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
&record_fields(t),
&aliases,
);
declarations.push((t.id, out));
@@ -1757,9 +1742,7 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Element(kind))
} else if legacy {
let defaults = if legacy {
crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
@@ -1816,12 +1799,7 @@ pub(crate) fn resolve_declarations(
let mut body =
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
.unwrap();
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)?;
let merged = merge_fields(payload(&body), 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()))?;
@@ -1846,10 +1824,7 @@ pub(crate) fn resolve_declarations(
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
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)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
record.insert("nodes".into(), serde_json::json!({}));
@@ -1864,10 +1839,7 @@ 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 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)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
let node: NodePlacement = serde_json::from_value(Value::Object(record))
@@ -1879,10 +1851,7 @@ 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 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)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let artifact = serde_json::from_value(Value::Object(record))
@@ -1898,10 +1867,7 @@ pub(crate) fn resolve_declarations(
}
}
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
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)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = if project.schema_version < 5 {
-1
View File
@@ -10,7 +10,6 @@ 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;
-382
View File
@@ -1,382 +0,0 @@
//! 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::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})));
}
}
}
// 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
@@ -1,718 +0,0 @@
//! 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);
}
}
+4 -6
View File
@@ -40,7 +40,6 @@ pub enum DiskExpectation {
/// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`;
/// use `diagnostics` to explain files that discovery could not read or parse.
/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization.
/// Expectations always describe disk bytes before UUID stabilization.
#[derive(Debug, Clone)]
pub struct DiskProject {
@@ -527,11 +526,10 @@ pub fn discover_missing(
result.expectations.keys().chain(result.bundle.files.keys()),
)?;
if result.diagnostics.is_empty() {
// Discovery retains raw editor bytes. Compilation may insert UUIDs or
// rewrite older syntax, so its returned locations belong to a different
// bundle. Lint still performs semantic migration and model validation;
// Open/Apply separately compile and install the rewritten source.
result.diagnostics = lint_bundle(&result.reachable_bundle(), limits);
match compile_bundle(&result.reachable_bundle(), limits) {
Ok(compiled) => result.diagnostics = compiled.diagnostics,
Err(diagnostics) => result.diagnostics = diagnostics,
}
}
let pending = journal_path(&result.root, &result.bundle.entry);
if fs::symlink_metadata(&pending).is_ok() {
+20 -131
View File
@@ -726,8 +726,7 @@ 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, language::DeclarationFormat::Compact);
let mut declarations = language::format_declarations(project);
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
for (id, declaration) in declarations {
@@ -736,13 +735,11 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.or_default()
.push_str(&unindent(&declaration));
}
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)
));
}
let mut entry = format!(
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
for path in files.keys() {
entry.push_str(&format!(" include {};\n", quote(path)));
}
@@ -853,25 +850,8 @@ pub fn update_bundle_with_identity_map(
)]);
}
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).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 before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let old_aliases: BTreeMap<_, _> = old
.identities()
.into_iter()
@@ -927,27 +907,6 @@ 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")
@@ -964,7 +923,6 @@ 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,
@@ -995,7 +953,6 @@ 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,
@@ -1054,7 +1011,7 @@ pub fn update_bundle_with_identity_map(
project,
identities,
&original_order,
&compact,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
@@ -1162,73 +1119,10 @@ 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
@@ -1237,16 +1131,6 @@ 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>,
@@ -1359,7 +1243,7 @@ impl SyntaxUpdate<'_> {
_ => {
erase_syntax(self.source, actual.span, edits);
let text = inserted_value(
self.inserted(after),
&self.after[after.span.start..after.span.end],
indent_at(self.source, actual.span.start),
self.source,
);
@@ -1424,9 +1308,6 @@ 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))
@@ -1445,7 +1326,11 @@ 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.inserted(&field.value), &indent, self.source);
let value = inserted_value(
&self.after[field.value.span.start..field.value.span.end],
&indent,
self.source,
);
if multiline {
text.push_str(&indent);
text.push_str(&format!("{key} {value};{}", newline(self.source)));
@@ -1565,7 +1450,11 @@ impl SyntaxUpdate<'_> {
} else {
text.push(' ');
}
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
text.push_str(&inserted_value(
&self.after[next.span.start..next.span.end],
&indent,
self.source,
));
suffix = "";
}
if i + 1 < after.len() {
@@ -11,10 +11,7 @@ pub(super) fn upgrade(
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
@@ -62,7 +59,6 @@ pub(super) fn upgrade(
source,
before: source,
after,
insertion: None,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
@@ -1,344 +0,0 @@
//! 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",
);
}
}
@@ -1,445 +0,0 @@
//! 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 }),
..
},
..
})
));
}
@@ -1,256 +0,0 @@
//! 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")
);
}
}
@@ -1,612 +0,0 @@
//! 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]);
}
@@ -1,222 +0,0 @@
//! 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()
);
}
+13 -34
View File
@@ -3,16 +3,6 @@ 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!(
@@ -763,8 +753,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];",
@@ -786,7 +776,6 @@ 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,
@@ -795,42 +784,32 @@ 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 [
replace_once(
&canonical,
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
canonical.replace(
"triggers [@deadline, @assess];",
"triggers [\"deadline\", @assess];",
),
replace_once(
&canonical,
canonical.replace(
"triggers [@deadline, @assess];",
"triggers [@deadline, @deadline];",
),
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
replace_once(&canonical, "kind After;", "kind Relative;"),
replace_once(&canonical, "items [@payload];", "items [@assess];"),
canonical.replace("effects [@assess];", "effects [@payload];"),
canonical.replace("kind After;", "kind Relative;"),
canonical.replace("items [@payload];", "items [@assess];"),
] {
assert!(
language::parse(&invalid).is_err(),
"invalid typed state/event source accepted"
);
}
// 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 = canonical
.replace("transition_kind External;", "")
.replace("triggers [@deadline, @assess];", "")
.replace("effects [@assess];", "");
let legacy = read(&legacy);
let RelationBody::StateTransition {
transition_kind,
@@ -850,7 +829,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
(retained_trigger, retained_guard, retained_effect),
(trigger, guard, effect)
);
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
assert!(language::parse(&draft).is_ok());
let deadline = model
.elements
@@ -1059,7 +1038,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,
+1 -9
View File
@@ -767,15 +767,7 @@ 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"));
// 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
);
assert!(!text.contains("port_anchors"));
let prepared = prepare_command(
&cleared.decoded.project,
&Command::SetPortAnchor {
-84
View File
@@ -383,87 +383,3 @@ fn atomic_save_preserves_existing_unix_permissions() {
0o640
);
}
#[test]
fn discovery_diagnostics_keep_raw_no_uuid_spans() {
let directory = setup();
let path = directory.path().join("parts/a.arcadia");
let raw =
"// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
fs::write(&path, raw).unwrap();
let disk = load_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default(),
)
.unwrap();
assert_eq!(disk.bundle.files["parts/a.arcadia"], raw);
let issue = disk
.diagnostics
.iter()
.find(|issue| {
issue.diagnostic.code == "unallocated_leaf"
&& issue.diagnostic.message.contains("Second")
})
.unwrap();
assert_eq!(issue.path, "parts/a.arcadia");
assert_eq!(issue.diagnostic.line, 3);
assert_eq!(issue.diagnostic.column, 1);
let span = issue.diagnostic.span;
assert_eq!(span.start, raw.find("sa_function second").unwrap());
assert_eq!(
&raw[span.start..span.end],
"sa_function second \"Second\" {}"
);
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_ne!(compiled.bundle, disk.bundle);
assert!(compiled.diagnostics.iter().any(|later| {
later.diagnostic.code == issue.diagnostic.code
&& later.diagnostic.message == issue.diagnostic.message
&& later.diagnostic.span.start > span.start
}));
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn discovery_diagnoses_legacy_semantics_before_source_rewrite() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project.arcadia");
let raw = include_str!("fixtures/v3-data-contract.arcadia");
fs::write(&path, raw).unwrap();
let disk = load_project(&path, ProjectLimits::default()).unwrap();
assert_eq!(disk.bundle.files["project.arcadia"], raw);
assert!(!disk.diagnostics.is_empty());
assert!(
disk.diagnostics
.iter()
.all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning })
);
assert_eq!(
disk.diagnostics,
lint_bundle(&disk.reachable_bundle(), ProjectLimits::default())
);
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
assert_ne!(compiled.bundle, disk.bundle);
for issue in &disk.diagnostics {
assert!(compiled.diagnostics.iter().any(|later| {
later.diagnostic.code == issue.diagnostic.code
&& later.diagnostic.object_id == issue.diagnostic.object_id
&& later.diagnostic.message == issue.diagnostic.message
}));
let span = issue.diagnostic.span;
assert!(raw.get(span.start..span.end).is_some());
}
// Invalid legacy references still fail during semantic resolution on the
// authored bytes; deferring the source rewrite does not hide those errors.
let invalid = raw.replace("@coordinate", "@missing");
fs::write(&path, &invalid).unwrap();
let broken = load_project(&path, ProjectLimits::default()).unwrap();
assert!(broken.diagnostics.iter().any(|issue| {
issue.diagnostic.severity == oa_core::validation::Severity::Error
&& issue.diagnostic.message.contains("@missing")
}));
assert_eq!(broken.bundle.files["project.arcadia"], invalid);
assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err());
assert_eq!(fs::read_to_string(path).unwrap(), invalid);
}
-1
View File
@@ -18,7 +18,6 @@ serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
unicode-segmentation = "1.13.3"
[dev-dependencies]
iced_runtime = "0.14"
+57 -503
View File
@@ -7,9 +7,7 @@ mod diagram_selection_tests;
mod hosted;
#[cfg(test)]
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)]
@@ -416,15 +414,14 @@ pub enum Message {
IncludesLoaded(u64, Result<DiskProject, String>),
Lint(u64),
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
TextWorkspace(text_workspace::Event),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
crate::source_navigation::SymbolEvent,
),
SourceSymbolsReady(crate::source_navigation::Completion),
SourceRename(source_rename::Event),
SourceRenameReady(source_rename::Completion),
SourceForSelection,
SourceNavigation(crate::source_navigation::Event),
SourceFindClose(crate::source_navigation::SymbolContext, Id),
@@ -503,8 +500,6 @@ 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 model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_version: u64,
@@ -612,8 +607,6 @@ 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(),
model_issues,
migration: None,
source_version: 0,
@@ -660,62 +653,19 @@ impl App {
|| self.behavior.has_pending()
}
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.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.source_version += 1;
self.lint_requested = None;
}
fn show_source_file(&mut self, restore_cursor: bool) {
self.show_source_file_with_alias_editor(restore_cursor, None);
}
fn show_source_file_with_alias_editor(
&mut self,
restore_cursor: bool,
hint: Option<source_rename::EditorRefresh<'_>>,
) {
self.source_symbols.reveal = None;
self.source_tree.reveal(&self.source_workspace.active);
self.source_select_line = false;
let source = self.source_workspace.text().to_owned();
self.source_navigation.refresh(&source);
if !hint.is_some_and(|hint| {
hint.apply(
&mut self.source_editor,
&self.source_workspace.active,
&source,
)
}) {
self.source_editor = text_editor::Content::with_text(&source);
}
self.source_editor = text_editor::Content::with_text(&source);
self.source_snapshot = Arc::from(source);
self.source_jump = restore_cursor
.then(|| {
@@ -760,9 +710,6 @@ impl App {
}
}
fn sync(&mut self) {
self.sync_with_alias_editor(None);
}
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.model_issues = validate(self.project());
self.normalize_creation();
@@ -792,7 +739,7 @@ impl App {
.map(|d| d.id);
}
self.refresh_draft();
self.sync_source_with_alias_editor(hint);
self.sync_source();
}
fn normalize_creation(&mut self) {
if !self.create_kind.supports(self.layer) {
@@ -831,7 +778,7 @@ impl App {
}
}
Err(issues) => {
self.publish_source_diagnostics(issues);
self.source_diagnostics = issues;
self.status = "Source contains errors. Your draft is preserved.".into();
self.page = Page::Text;
false
@@ -985,8 +932,6 @@ impl App {
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let navigation_in_flight = self.source_symbols.in_flight;
let rename_in_flight = self.source_rename.in_flight;
let 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();
@@ -999,8 +944,6 @@ impl App {
self.source_version = source_version;
self.lint_running = lint_running;
self.source_symbols.in_flight = navigation_in_flight;
self.source_rename.in_flight = rename_in_flight;
self.menu_sequence = menu_sequence;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1098,7 +1041,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).or_else(|| self.text_workspace_menu()),
crate::model_menu::popup(self),
)
.into();
crate::source_navigation::interaction::observe(
@@ -1115,14 +1058,11 @@ 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.text_workspace.menu().is_none()
&& (self.behavior.creation.has_pending()
|| 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())),
self.behavior.creation.has_pending()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.source_symbols.open || self.source_navigation.open)
&& self.architecture.is_none()),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
@@ -1169,16 +1109,6 @@ 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);
}
if let Message::SourceRename(event) = &message
&& !self.source_rename_envelope(event)
{
return Task::none();
}
let message = match message {
Message::SourceFindClose(context, instance) => {
if context != self.source_symbol_context()
@@ -1202,65 +1132,12 @@ impl App {
{
return Task::none();
}
// Retaining result data does not retain authority for an old row click.
if let Message::SourceSymbols(
_,
crate::source_navigation::SymbolEvent::Choose { stamp, visit, .. },
) = &message
&& (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_none_or(|results| results.stamp != *stamp))
{
return Task::none();
}
// Internal results and geometry do not constitute a new user intent.
// Process query completion even behind a modal to release its worker slot;
// its handler independently refuses navigation outside the live editor.
if let Message::SourceSymbolsReady(completion) = message {
return self.source_symbols_ready(completion);
}
if let Message::SourceRenameReady(completion) = message {
return self.source_rename_ready(completion);
}
if 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
@@ -1451,18 +1328,6 @@ impl App {
{
return Task::none();
}
if self.source_rename.open() {
if matches!(
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
self.close_source_rename();
return Task::none();
}
if !modal_event && !matches!(message, Message::SourceRename(_)) {
return Task::none();
}
}
if matches!(
message,
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
@@ -1507,7 +1372,6 @@ impl App {
self.last_workspace_page = self.page;
}
let mut task = self.handle(message);
self.refresh_source_rename_context();
self.route_state_diagram_workspace();
if self.page != Page::Home {
self.last_workspace_page = self.page;
@@ -1937,11 +1801,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(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 {
let Some(opened) = crate::model_menu::Opened::new(
self,
request,
self.menu_sequence.wrapping_add(1),
) else {
return Task::none();
};
self.connection = None;
@@ -1995,8 +1859,7 @@ impl App {
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
&& (self.source_navigation.open || self.source_symbols.open);
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
@@ -2013,11 +1876,9 @@ impl App {
self.status = "Drawing cancelled · Select / move is active".into();
}
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);
}
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
if restore_source_focus {
self.restore_source_editor_focus();
}
@@ -2995,7 +2856,7 @@ impl App {
.edit(after.clone(), self.source_editor.cursor());
self.source_snapshot = Arc::from(after);
self.source_version += 1;
self.publish_source_diagnostics(Vec::new());
self.source_diagnostics.clear();
let version = self.source_version;
return Task::perform(
async move {
@@ -3007,12 +2868,10 @@ impl App {
}
}
Message::SourceFile(path) => {
if path != self.source_workspace.active
&& self.source_workspace.files.contains_key(&path)
if self
.source_workspace
.switch(&path, self.source_editor.cursor())
{
let departure =
crate::source_navigation::selection::capture(&mut self.source_editor);
self.source_workspace.switch(&path, departure);
self.show_source_file(true);
}
}
@@ -3058,15 +2917,9 @@ impl App {
if self.pending_recovery.as_ref() != Some(&receipt.target) {
return Task::none();
}
self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again.");
self.pending_recovery = None;
let diagnostics = self
.source_diagnostics
.iter()
.filter(|issue| issue.diagnostic.code != "pending_save")
.cloned()
.collect();
self.publish_source_diagnostics(diagnostics);
self.source_diagnostics
.retain(|issue| issue.diagnostic.code != "pending_save");
match receipt.disk {
Ok(disk) if !self.source_workspace.applied => {
self.open_source_project(disk)
@@ -3078,7 +2931,7 @@ impl App {
|| self.path.as_ref() == Some(&receipt.target)
{
if self.path.as_ref() != Some(&receipt.target) {
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
}
self.path = Some(receipt.target);
self.document.bind_repair_baseline(disk.reachable_bundle());
@@ -3106,24 +2959,13 @@ 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.publish_source_diagnostics(disk.diagnostics.clone());
self.source_diagnostics = disk.diagnostics.clone();
self.disk_project = Some(disk);
self.status = "Included files loaded; existing drafts retained.".into();
return self.request_lint();
@@ -3144,18 +2986,24 @@ impl App {
self.lint_running = None;
if version == self.source_version {
match result {
Ok(diagnostics) => self.publish_source_diagnostics(diagnostics),
Ok(diagnostics) => self.source_diagnostics = diagnostics,
Err(error) => self.status = format!("Source checks failed: {error}"),
}
}
return self.start_pending_lint();
}
Message::TextWorkspace(_) => return Task::none(), // checked before generic input
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::SourceViewport(ticket) => return self.source_viewport(ticket),
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
Message::SourceRename(event) => return self.source_rename_event(event),
Message::SourceRenameReady(completion) => return self.source_rename_ready(completion),
Message::SourceSymbolsReady(completion) => {
return self.source_symbols_ready(completion);
}
@@ -3174,7 +3022,7 @@ impl App {
if self.source_workspace.undo(redo) {
self.show_source_file(true);
self.source_version += 1;
self.publish_source_diagnostics(Vec::new());
self.source_diagnostics.clear();
return self.request_lint();
}
}
@@ -3322,7 +3170,7 @@ impl App {
self.sync_source();
}
if self.path.as_ref() != Some(&receipt.path) {
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
@@ -3864,7 +3712,7 @@ impl App {
self.source_workspace
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
self.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();
@@ -3884,10 +3732,9 @@ 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.publish_source_diagnostics(error.diagnostics().to_vec());
self.source_diagnostics = error.diagnostics().to_vec();
self.source_version += 1;
self.lint_requested = None;
self.page = Page::Text;
@@ -3917,14 +3764,17 @@ impl App {
}
fn jump_to_source(&mut self, location: SourceLocation) {
let Some(target) = self.source_workspace.files.get(&location.path) else {
self.source_workspace
.switch(&location.path, self.source_editor.cursor());
if self.source_workspace.active != location.path {
self.status = format!(
"{} has not been loaded. Use Load includes or create the missing file.",
location.path
);
return;
};
let source = &target.text;
}
self.show_source_file(false);
let source = self.source_workspace.text();
let mut offset = location.span.start.min(source.len());
while !source.is_char_boundary(offset) {
offset -= 1;
@@ -3934,9 +3784,6 @@ impl App {
let column = prefix
.rfind('\n')
.map_or(offset, |start| offset - start - 1);
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
self.source_workspace.switch(&location.path, departure);
self.show_source_file(false);
self.queue_source_reveal(
text_editor::Cursor {
position: text_editor::Position { line, column },
@@ -3963,7 +3810,6 @@ impl App {
.is_ok_and(|includes| includes.iter().any(|include| include.target == path))
});
let mut changed = bundle.clone();
let mut changed_entry = None;
if !already_included {
if self.source_dirty() {
return Err(
@@ -3983,30 +3829,16 @@ impl App {
serde_json::to_string(&path).map_err(|e| e.to_string())?
),
);
changed_entry = Some(entry.clone());
}
// All fallible preparation is complete. The intermediate entry buffer
// is not displayed, so it must not receive the outgoing editor's cursor.
let original_path = self.source_workspace.active.clone();
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
if let Some(entry) = changed_entry {
self.source_workspace.switch(&bundle.entry, departure);
let cursor = if self.source_workspace.active == original_path {
departure
} else {
self.source_workspace.files[&self.source_workspace.active].cursor
};
self.source_workspace.edit(entry, cursor);
self.source_workspace
.switch(&bundle.entry, self.source_editor.cursor());
self.source_workspace
.edit(entry.clone(), self.source_editor.cursor());
}
changed.files.insert(path.clone(), String::new());
self.source_workspace
.discovered(&changed, changed.files.keys().cloned().collect());
let cursor = if self.source_workspace.active == original_path {
departure
} else {
self.source_workspace.files[&self.source_workspace.active].cursor
};
self.source_workspace.switch(&path, cursor);
self.source_workspace
.switch(&path, self.source_editor.cursor());
self.source_workspace.new_path.clear();
self.show_source_file(true);
self.source_version += 1;
@@ -4952,16 +4784,7 @@ 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::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);
send(&mut app, Message::SourceDiagnostic(0));
assert_eq!(app.source_workspace.active, fragment);
assert_eq!(app.source_editor.text(), "? invalid source");
send(&mut app, Message::RevertSource);
@@ -5003,275 +4826,6 @@ 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();
+2 -2
View File
@@ -219,7 +219,7 @@ impl App {
|| session.connection.base() != request.connection.base()
})
{
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
@@ -359,7 +359,7 @@ impl App {
|| session.connection.base() != connection.base()
})
{
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
File diff suppressed because it is too large Load Diff
@@ -1,219 +0,0 @@
//! Keep Iced's single-line input from silently filtering pasted control bytes.
//! Only the alias field uses this adapter. The child retains editing and focus.
use iced::advanced::{
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
};
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
) -> Element<'a, Message, Theme, R> {
Element::new(AliasInput { input, rejected })
}
struct AliasInput<'a, Message, Theme, R> {
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
}
#[derive(Default)]
struct State {
modifiers: keyboard::Modifiers,
}
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
// previously admitted paste while V is held, until KeyReleased clears that cache.
struct CapturedClipboard<'a> {
content: Option<String>,
original: &'a mut dyn Clipboard,
}
impl Clipboard for CapturedClipboard<'_> {
fn read(&self, kind: clipboard::Kind) -> Option<String> {
if kind == clipboard::Kind::Standard {
self.content.clone()
} else {
self.original.read(kind)
}
}
fn write(&mut self, kind: clipboard::Kind, contents: String) {
self.original.write(kind, contents);
}
}
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
for AliasInput<'_, Message, Theme, R>
{
fn size(&self) -> Size<Length> {
self.input.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.input.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.input.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.input));
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.input
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.input.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.input
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let focused = tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused();
let state = tree.state.downcast_mut::<State>();
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
}
let paste = matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
if key.to_latin(*physical_key) == Some('v')
&& state.modifiers.command() && !state.modifiers.alt());
if focused
&& paste
&& let Some(rejected) = &self.rejected
{
let content = clipboard.read(clipboard::Kind::Standard);
if content
.as_ref()
.is_some_and(|value| value.chars().any(char::is_control))
{
// Do not invoke the child: even an empty replacement would erase
// its selection. Approval invalidation is an explicit App event.
shell.publish(rejected.clone());
shell.capture_event();
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
&mut CapturedClipboard {
content,
original: clipboard,
},
shell,
viewport,
);
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
// Escape branch, which also resets stored modifiers. Observe that branch
// after its text/shortcut early returns and effective-key conversion.
if focused
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
&& !tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused()
{
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.input.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.input.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -1,220 +0,0 @@
use super::*;
use crate::views::source_tests::FontLayout;
use iced::keyboard::{
Key, Modifiers,
key::{Code, Named, Physical},
};
use std::cell::Cell;
#[derive(Debug, Clone, PartialEq)]
enum Signal {
Input(String),
Rejected,
}
struct ClipboardText {
text: String,
reads: Cell<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
self.reads.set(self.reads.get() + 1);
Some(self.text.clone())
}
fn write(&mut self, _: clipboard::Kind, _: String) {}
}
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
struct Mounted {
input: Element<'static, Signal, iced::Theme, FontLayout>,
tree: widget::Tree,
node: layout::Node,
clipboard: ClipboardText,
messages: Vec<Signal>,
}
impl Mounted {
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
let mut child = iced::widget::text_input("Alias", "send_water");
if enabled {
child = child.on_input(Signal::Input);
}
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
let mut tree = widget::Tree::new(input.as_widget());
let node = input.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
);
if focused {
let state = tree.children[0].state.downcast_mut::<InputState>();
state.focus();
state.select_range(0, 10);
}
let mut mounted = Self {
input,
tree,
node,
clipboard: ClipboardText {
text: "foo\nbar".into(),
reads: Cell::new(0),
},
messages: vec![],
};
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)));
mounted
}
fn state(&self) -> &InputState {
self.tree.children[0].state.downcast_ref::<InputState>()
}
fn event(&mut self, event: Event) -> bool {
let mut shell = Shell::new(&mut self.messages);
self.input.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Unavailable,
&FontLayout,
&mut self.clipboard,
&mut shell,
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
);
shell.is_event_captured()
}
}
fn key(key: Key, modified_key: Key, code: Code) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key,
modified_key,
physical_key: Physical::Code(code),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
})
}
fn paste() -> Event {
key(
Key::Character("v".into()),
Key::Character("v".into()),
Code::KeyV,
)
}
#[test]
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
for focused in [false, true] {
for enabled in [false, true] {
for modifiers in [
Modifiers::empty(),
Modifiers::SHIFT,
Modifiers::COMMAND,
Modifiers::COMMAND | Modifiers::ALT,
Modifiers::COMMAND | Modifiers::SHIFT,
] {
let mut mounted = Mounted::new(focused, enabled, modifiers);
let before = mounted.state().cursor();
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
let captured = mounted.event(paste());
assert_eq!(
mounted.messages,
if expected {
vec![Signal::Rejected]
} else {
vec![]
}
);
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
assert_eq!(mounted.state().cursor(), before);
if expected {
assert!(captured);
}
}
}
}
}
#[test]
fn effective_escape_reset_follows_child_after_early_returns() {
// Raw Escape mapped to Home must not clear only the guard's Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Escape),
Key::Named(Named::Home),
Code::Escape,
));
assert!(mounted.state().is_focused());
assert!(mounted.event(paste()));
assert_eq!(mounted.messages, vec![Signal::Rejected]);
// Effective Escape clears both states; a later focus operation does not
// invent a new ModifiersChanged event or revive the previous Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
));
assert!(!mounted.state().is_focused());
mounted.tree.children[0]
.state
.downcast_mut::<InputState>()
.focus();
mounted.event(paste());
assert!(mounted.messages.is_empty());
assert_eq!(mounted.clipboard.reads.get(), 0);
// Printable text returns before effective Escape and leaves modifiers live.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
let mut event = key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
);
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
*text = Some("x".into());
}
mounted.event(event);
assert!(mounted.state().is_focused());
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
mounted.messages.clear();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
}
#[test]
fn held_paste_cache_contains_only_previously_admitted_text() {
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.clipboard.text = "water".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
mounted.messages.clear();
mounted.clipboard.text = "other".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwater".into())],
"Iced reuses its first admitted paste while the key is held"
);
mounted.messages.clear();
let before = mounted.state().cursor();
mounted.clipboard.text = "bad\tvalue".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
assert_eq!(mounted.state().cursor(), before);
mounted.messages.clear();
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
key: Key::Character("v".into()),
modified_key: Key::Character("v".into()),
physical_key: Physical::Code(Code::KeyV),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
}));
mounted.clipboard.text = "later".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwaterlater".into())]
);
}
@@ -1,303 +0,0 @@
//! Display-only reuse after a guarded alias rename. This is not model authority.
use iced::{advanced::text::Renderer, widget::text_editor};
use std::sync::Arc;
/// Replace exact, unchanged-line-count source in an existing editor.
///
/// The caller must first authorize/commit the model change and must reconstruct
/// from accepted source on false. Preflight failures do not mutate the editor;
/// a failed final readback can leave it changed. On changing success the cursor
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
/// Direct Content actions deliberately bypass App draft edits and Undo history.
pub(super) fn replace<R: Renderer>(
editor: &mut text_editor::Content<R>,
before: &str,
after: &str,
) -> bool {
if editor.text() != before {
return false;
}
let Some(old_lines) = lines(before) else {
return false;
};
let Some(new_lines) = lines(after) else {
return false;
};
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
return false;
}
let mut patches = Vec::new();
for (line, ((old, old_ending), (new, new_ending))) in
old_lines.into_iter().zip(new_lines).enumerate()
{
let Some(actual) = editor.line(line) else {
return false;
};
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
return false;
}
if old == new {
continue;
}
let mut start = old
.bytes()
.zip(new.bytes())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
start -= 1;
}
let mut suffix = old[start..]
.bytes()
.rev()
.zip(new[start..].bytes().rev())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
{
suffix -= 1;
}
// All positions and owned payloads are prepared before the first edit.
patches.push((
line,
start,
old.len() - suffix,
Arc::new(new[start..new.len() - suffix].to_owned()),
));
}
if patches.is_empty() {
return true;
}
let empty = Arc::new(String::new());
for (line, start, end, replacement) in patches.into_iter().rev() {
select(editor, line, start, end);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
replacement,
)));
}
// Iced overwrites its earliest-change marker on each Edit. An explicit
// zero-width selection + empty Paste changes no bytes, but marks line zero.
// This also refreshes previously unhighlighted lines before the first patch.
// It resets only line zero's text cache; other unchanged lines retain theirs.
select(editor, 0, 0, 0);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
editor.text() == after
}
fn select<R: Renderer>(
editor: &mut text_editor::Content<R>,
line: usize,
start: usize,
end: usize,
) {
editor.move_to(text_editor::Cursor {
position: text_editor::Position {
line,
column: start,
},
// move_to(None) does not clear a previous selection in this Iced version.
selection: Some(text_editor::Position { line, column: end }),
});
}
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
use text_editor::LineEnding;
let mut result = Vec::new();
let mut parts = source.split('\n').peekable();
while let Some(part) = parts.next() {
let (text, ending) = if parts.peek().is_none() {
(part, LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, LineEnding::CrLf)
} else {
(part, LineEnding::Lf)
};
if text.contains('\r')
|| (text.is_empty()
&& ending == LineEnding::CrLf
&& result
.last()
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
{
return None;
}
result.push((text, ending));
}
Some(result)
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{layout, text::Highlighter, widget::Tree};
use iced::{Element, Font, Length, Size};
use std::{cell::RefCell, ops::Range};
#[test]
fn exact_text_matrix_and_reverse_preserve_endings() {
for (before, after) in [
("", ""),
("", "alias"),
("alias", ""),
("same", "same"),
("a\n", "alias\n"),
("a\r\n", "alias\r\n"),
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
("\na\n\n", "\nlong_alias\n\n"),
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
(
"雨🙂 e\u{301} @a @a // a",
"雨🙂 e\u{301} @a_long @a_long // a",
),
("@abc", "@abcdef"),
("@abcdef", "@abc"),
("@abc_suffix", "@def_suffix"),
("@none", "@identifier"),
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
// not be used as String split positions, even outside alias scope.
("雨xé", "雪xê"),
("é", "©"),
(
"unchanged\n@a\nunchanged\n@a\n",
"unchanged\n@long\nunchanged\n@long\n",
),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
assert!(
replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), after);
let expected = text_editor::Content::<FontLayout>::with_text(after);
assert_eq!(editor.line_count(), expected.line_count());
for (actual, expected) in editor.lines().zip(expected.lines()) {
assert_eq!(actual.text, expected.text);
assert_eq!(actual.ending, expected.ending);
}
if before != after {
assert_eq!(
editor.cursor().position,
text_editor::Position { line: 0, column: 0 }
);
assert_eq!(editor.cursor().selection, None);
}
assert!(replace(&mut editor, after, before));
assert_eq!(editor.text(), before);
}
}
#[test]
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
for (actual, before, after) in [
("live", "stale", "changed"),
("a\nb", "a\nb", "long\nb\nc"),
("a\nb", "a\nb", "long\r\nb"),
// Refusal on a later line must not apply the earlier planned edit.
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
("a\rb", "a\rb", "long\rb"),
("a\n\rb", "a\n\rb", "long\n\rb"),
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
// The replacement must not introduce a greedy LFCR boundary either.
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
("a", "a", "long\r"),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
select(&mut editor, 0, 0, 1);
let cursor = editor.cursor();
let selection = editor.selection();
assert!(
!replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), actual);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.selection(), selection);
}
}
#[test]
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
select(&mut editor, 0, 1, 2);
let cursor = editor.cursor();
assert!(replace(&mut editor, "@a", "@a"));
assert_eq!(editor.cursor(), cursor);
assert!(replace(&mut editor, "@a", "@long"));
editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 5 },
selection: None,
});
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
assert_eq!(editor.text(), "@long!");
}
thread_local! {
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
}
struct Tracked(crate::highlight::ArcadiaHighlighter);
impl Highlighter for Tracked {
type Settings = Arc<str>;
type Highlight = oa_core::language::TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
fn new(settings: &Self::Settings) -> Self {
Self(crate::highlight::ArcadiaHighlighter::new(settings))
}
fn update(&mut self, settings: &Self::Settings) {
self.0.update(settings);
}
fn change_line(&mut self, line: usize) {
CHANGES.with(|changes| changes.borrow_mut().push(line));
self.0.change_line(line);
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
self.0.highlight_line(line)
}
fn current_line(&self) -> usize {
self.0.current_line()
}
}
fn view(
editor: &text_editor::Content<FontLayout>,
source: Arc<str>,
) -> Element<'_, (), iced::Theme, FontLayout> {
text_editor(editor)
.font(Font::MONOSPACE)
.size(14)
.height(Length::Fill)
.highlight_with::<Tracked>(source, crate::highlight::format)
.into()
}
#[test]
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
let before = "// earlier\n// unmodified\n@a";
let after = "// earlier\n// unmodified\n@none";
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
let mut tree = {
let mut widget = view(&editor, Arc::from(before));
let mut tree = Tree::new(widget.as_widget());
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
tree
};
CHANGES.with(|changes| changes.borrow_mut().clear());
assert!(replace(&mut editor, before, after));
{
let mut widget = view(&editor, Arc::from(after));
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
}
// Font/metrics/bounds are already stable. This notification must come
// from the helper's empty edit, not a fresh renderer initialization.
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
assert_eq!(editor.text(), after);
}
}
@@ -1,449 +0,0 @@
//! Real paragraph advances and clipping through the actual preview dialog.
//! Native screenshots additionally check raster ink and the platform renderer.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
use std::collections::{BTreeMap, BTreeSet};
struct Glyph {
text: String,
paragraph_y: u32,
line: usize,
bytes: std::ops::Range<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct GlyphRecorder {
wanted: BTreeSet<String>,
transforms: Vec<Transformation>,
clips: Vec<Rectangle>,
glyphs: Vec<Glyph>,
quads: Vec<Rectangle>,
}
impl GlyphRecorder {
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
Self {
wanted,
transforms: vec![Transformation::IDENTITY],
clips: vec![viewport],
glyphs: vec![],
quads: vec![],
}
}
fn transform(&self) -> Transformation {
*self.transforms.last().unwrap()
}
fn clip(&self, bounds: Rectangle) -> Rectangle {
self.clips
.last()
.unwrap()
.intersection(&(bounds * self.transform()))
.unwrap_or(Rectangle::with_size(Size::ZERO))
}
}
impl renderer::Renderer for GlyphRecorder {
fn start_layer(&mut self, bounds: Rectangle) {
self.clips.push(self.clip(bounds));
}
fn end_layer(&mut self) {
self.clips.pop().unwrap();
}
fn start_transformation(&mut self, transform: Transformation) {
self.transforms.push(self.transform() * transform);
}
fn end_transformation(&mut self) {
self.transforms.pop().unwrap();
}
fn reset(&mut self, bounds: Rectangle) {
self.transforms = vec![Transformation::IDENTITY];
self.clips = vec![bounds];
self.glyphs.clear();
self.quads.clear();
}
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
self.quads.push(quad.bounds * self.transform());
}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for GlyphRecorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(
&mut self,
paragraph: &Self::Paragraph,
position: Point,
_: Color,
clip: Rectangle,
) {
let buffer = paragraph.buffer();
let text = buffer
.lines
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n");
if !self.wanted.contains(&text) {
return;
}
let clip = self.clip(clip);
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if run.text[glyph.start..glyph.end]
.chars()
.all(char::is_whitespace)
{
continue;
}
let bounds = Rectangle {
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
y: position.y + run.line_top,
width: glyph.w,
height: run.line_height,
} * self.transform();
self.glyphs.push(Glyph {
text: text.clone(),
paragraph_y: position.y.to_bits(),
line: run.line_i,
bytes: glyph.start..glyph.end,
bounds,
clip,
});
}
}
}
fn fill_editor(
&mut self,
editor: &Self::Editor,
position: Point,
color: Color,
clip: Rectangle,
) {
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
}
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
}
}
#[derive(Default)]
struct ScrollGeometry {
id: Option<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
offset: Vector,
}
impl widget::Operation for ScrollGeometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == self.id.as_ref() {
self.bounds = Some(bounds);
self.content = Some(content);
self.offset = offset;
}
}
}
fn normalized(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
text.split('\n')
.enumerate()
.flat_map(|(line, text)| {
text.char_indices()
.filter(|(_, ch)| !ch.is_whitespace())
.map(move |(byte, _)| (line, byte))
})
.collect()
}
fn declarations(long: bool) -> App {
let name = if long {
format!("Assess {} request", "界🚀".repeat(90))
} else {
"Assess 救援 request".into()
};
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
]) };
let mut app = fixture();
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "救援/assess.arcadia".into();
app.selected = app.project().id;
app.refresh_draft();
app.show_source_file(false);
let start = app
.source_workspace
.text()
.find("oa_activity assess")
.unwrap()
+ "oa_activity ".len();
app.source_editor.move_to(cursor(
app.source_workspace.text(),
start,
start + "assess".len(),
));
begin(&mut app);
let replacement = if long {
"a".repeat(255)
} else {
"assess_damage".into()
};
input(&mut app, &replacement);
let _ = preview(&mut app);
app
}
fn assert_page_glyphs_visible(app: &App, host: Size) {
let form = app.source_rename.form.as_ref().unwrap();
let ready = form.approval.as_ref().unwrap();
let file = &ready.preview.files()[form.file];
let mut expected = BTreeMap::<String, usize>::new();
for line in file.hunks()[form.hunk]
.lines()
.iter()
.skip(form.line)
.take(LINES_PER_PAGE)
{
for source in [
line.before_text(&ready.prepared.before().bundle.files[file.path()])
.unwrap(),
line.after_text(&ready.prepared.after().bundle.files[file.path()])
.unwrap(),
] {
if let Some(fragment) = preview::text_segment(source, form.segment) {
let text = normalized(fragment);
if !required_chars(&text).is_empty() {
*expected.entry(text).or_default() += 1;
}
}
}
}
assert!(!expected.is_empty());
let viewport = Rectangle::new(Point::ORIGIN, host);
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
let editor = text_editor::Content::<GlyphRecorder>::new();
let mut view = dialog_content(app, &editor);
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
let mut geometry = ScrollGeometry {
id: Some(preview_scroll_id(form, ready.id)),
..ScrollGeometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
let bounds = geometry.bounds.unwrap();
let content = geometry.content.unwrap();
assert!(bounds.height >= 40.);
let maximum = (content.height - bounds.height).max(0.);
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
let mut reserved_bar_gap = true;
for step in 0..=steps {
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut widget::operation::scrollable::scroll_to(
preview_scroll_id(form, ready.id),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
),
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
assert!(
(geometry.offset.y - offset).abs() <= 0.5,
"actual scroll operation reaches the requested source offset"
);
renderer::Renderer::reset(&mut renderer, viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&iced::Theme::Light,
&renderer::Style::default(),
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
// The vertical scrollbar's actual painted thumb fixes its excluded x
// region; the outer scroll clip alone cannot detect overpainting.
let scrollbar_left = renderer
.quads
.iter()
.filter(|quad| {
quad.width > 0.
&& quad.width <= 16.
&& quad.height > 0.
&& quad.x >= bounds.x + bounds.width - 18.
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
&& quad.y >= bounds.y - 0.5
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
})
.map(|quad| quad.x)
.min_by(f32::total_cmp);
if maximum > 0. {
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
reserved_bar_gap &= content.x + content.width <= left - 7.5;
}
for glyph in &renderer.glyphs {
let right = glyph.bounds.x + glyph.bounds.width;
if let Some(left) = scrollbar_left {
assert!(
right <= left - 0.5,
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
glyph.text,
glyph.bytes,
glyph.bounds
);
}
assert!(
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
"source glyph escaped reserved width: {:?} {:?}",
glyph.text,
glyph.bounds
);
assert!(
glyph.bounds.x >= glyph.clip.x - 0.5
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
"source glyph horizontally clipped: {:?} {:?} {:?}",
glyph.text,
glyph.bounds,
glyph.clip
);
if glyph.bounds.y >= glyph.clip.y - 0.5
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
{
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
let chars = logical_line
.char_indices()
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
.map(|(byte, _)| (glyph.line, byte));
visible
.entry((glyph.text.clone(), glyph.paragraph_y))
.or_default()
.extend(chars);
}
}
}
assert!(
reserved_bar_gap,
"source content must reserve actual scrollbar width plus gap"
);
assert!(
bounds.width >= host.width - 30.,
"preview must use available dialog width: {bounds:?} / {host:?}"
);
for (text, count) in expected {
let required = required_chars(&text);
let instances: Vec<_> = visible
.iter()
.filter(|((value, _), _)| value == &text)
.collect();
assert_eq!(
instances.len(),
count,
"all before/after occurrences must be seen: {text:?}"
);
for (_, seen) in instances {
assert_eq!(
seen, &required,
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
);
}
}
}
#[test]
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
for long in [false, true] {
let mut app = declarations(long);
let ready = app
.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.clone();
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
let before = evidence(&app);
for (width, height) in [(1100., 700.), (580., 700.)] {
app.window_size = Size::new(width, height);
let host = Size::new((width - 84.).min(708.), height - 120.);
let total = segment_total(app.source_rename.form.as_ref().unwrap());
if long {
assert!(
total >= 3,
"include unbroken ASCII and wide Unicode across segments"
);
}
for segment in 0..total {
assert_page_glyphs_visible(&app, host);
if segment + 1 < total {
event(&mut app, Event::Segment(invocation, ready.id, true));
}
}
for _ in 1..total {
event(&mut app, Event::Segment(invocation, ready.id, false));
}
}
assert_eq!(
evidence(&app),
before,
"preview geometry/paging cannot alter transaction inputs"
);
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
ready.id
);
}
}
@@ -1,310 +0,0 @@
//! Execute the controller's scroll operations on a retained, scrolled dialog.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::{Rectangle, Size, Vector};
const HOST: Size = Size::new(720., 660.);
fn paged_fixture() -> App {
let mut source = bundle();
let name = "Long engineering name 雨 ".repeat(18);
source.files.get_mut("defs.arcadia").unwrap().replace_range(
..,
&format!(
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
),
);
let mut uses = String::new();
for index in 0..16 {
if index == 12 {
uses.push_str(&"// unchanged gap\r\n".repeat(10));
}
uses.push_str(&format!(
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
));
}
source.files.insert("uses.arcadia".into(), uses);
let mut app = fixture();
app.window_size = HOST;
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(cursor(app.source_workspace.text(), start, start + 4));
begin(&mut app);
input(&mut app, "pump_long");
let _ = preview(&mut app);
app
}
fn scroll_id(app: &App) -> widget::Id {
let form = app.source_rename.form.as_ref().unwrap();
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
}
struct Geometry {
id: widget::Id,
offset: Option<Vector>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
}
}
}
struct Mounted {
editor: text_editor::Content<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::new();
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
Self { editor, tree }
}
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut view = dialog_content(app, &self.editor);
// Preserve the actual scroll state across the new page's view diff.
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut()
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
}
fn offset(&mut self, app: &App) -> f32 {
let mut geometry = Geometry {
id: scroll_id(app),
offset: None,
};
self.operate(app, &mut geometry);
geometry.offset.expect("mounted preview scrollable").y
}
fn scroll_down(&mut self, app: &App) -> f32 {
self.operate(
app,
&mut widget::operation::scrollable::scroll_to(
scroll_id(app),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(10_000.),
},
),
);
let offset = self.offset(app);
assert!(offset > 10., "fixture must really be scrolled");
offset
}
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
let mut count = 0;
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("preview paging must only schedule a widget operation");
};
self.operate(app, operation.as_mut());
count += 1;
}
}
count
}
}
#[tokio::test]
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let old_id = scroll_id(&app);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
assert_ne!(old_id, scroll_id(&app));
// Negative control: even after diff/layout with the new id and new file,
// Iced retains the previous offset until the returned operation executes.
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
for navigation in [
Event::Line(invocation, approval, true),
Event::Segment(invocation, approval, true),
Event::Hunk(invocation, approval, true),
Event::File(invocation, approval, false),
Event::Search(invocation, "uses".into()),
Event::Search(invocation, "no-path-match".into()),
Event::Search(invocation, String::new()),
] {
mounted.scroll_down(&app);
let task = app.update(Message::SourceRename(navigation));
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
// Passive redraw and unchanged filter input preserve deliberate scrolling.
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Search(
invocation,
String::new(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Input(
invocation,
"pump_long".into(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, false,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"clamped first-page request is a no-op"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let old_approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let old_page_task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
let next_page_task = app.update(Message::SourceRename(Event::Line(
invocation,
old_approval,
true,
)));
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
let old_approval_task = app.update(Message::SourceRename(Event::Search(
invocation,
"defs".into(),
)));
input(&mut app, "pump_new");
let _ = preview(&mut app); // Real new approval in the same invocation/page.
assert_ne!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
old_approval
);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old approval target is absent"
);
let task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"stale envelope is rejected before scheduling"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let first_visit = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let next_page = app.update(Message::SourceRename(Event::Line(
invocation, approval, true,
)));
assert_eq!(mounted.execute(&app, next_page).await, 1);
let return_visit = app.update(Message::SourceRename(Event::Line(
invocation, approval, false,
)));
assert_eq!(mounted.execute(&app, return_visit).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, first_visit).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old visit to the identical page is stale"
);
let old_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"uses".into(),
)));
let new_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"us".into(),
)));
assert_eq!(mounted.execute(&app, new_filter).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_filter).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old filter on the same page is stale"
);
input(&mut app, "pump_new");
let job = start_preview(&mut app);
let completion = preview_completion(&app, job);
// No intermediate frame mounts the temporary no-approval view. The old
// Scrollable survives even though the new approval has a different id.
let ready = app.update(Message::SourceRenameReady(completion));
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, ready).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
File diff suppressed because it is too large Load Diff
@@ -1,313 +0,0 @@
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
//! Child of source_rename_timing_tests, preserving the separate real App test.
use super::*;
use crate::{highlight, views::source_tests::FontLayout};
use iced::advanced::widget::operation::Focusable;
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
use iced::{
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
};
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
#[derive(Default)]
struct ObservedFontLayout {
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
}
impl renderer::Renderer for ObservedFontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {
self.drawn = None;
}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for ObservedFontLayout {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
self.drawn = Some((editor.downgrade(), position, clip));
}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
type Content = text_editor::Content<ObservedFontLayout>;
const HOST: Size = Size::new(640., 350.);
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
text_editor(editor)
.on_action(|_| ())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Length::Fill)
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
.into()
}
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
assert_eq!(editor.text(), expected);
assert_eq!(editor.cursor(), cursor);
let lines: Vec<_> = expected.split('\n').collect();
assert_eq!(editor.line_count(), lines.len());
for (index, part) in lines.iter().enumerate() {
let (text, ending) = if index + 1 == lines.len() {
(*part, text_editor::LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, text_editor::LineEnding::CrLf)
} else {
(*part, text_editor::LineEnding::Lf)
};
let actual = editor.line(index).unwrap();
assert_eq!(actual.text, text);
assert_eq!(actual.ending, ending);
}
}
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
let editor = weak.upgrade().expect("drawn editor is still live");
let buffer = editor.buffer();
assert_eq!(weak.bounds, Size::new(608., 318.));
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
assert_eq!(buffer.metrics().font_size, 14.);
assert_eq!(
buffer.scroll().horizontal,
0.,
"fixture origin fits the viewport"
);
let start = cursor.position;
let end = cursor.selection.expect("approved alias selection");
assert_eq!(start.line, end.line);
assert_eq!(end.column - start.column, alias.len());
let line = &buffer.lines[start.line];
assert_eq!(&line.text()[start.column..end.column], alias);
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
let font = graphics::text::to_attributes(Font::MONOSPACE);
for byte in start.column..end.column {
let attrs = line.attrs_list().get_span(byte);
assert_eq!(
attrs.color_opt,
Some(color),
"accepted alias syntax attributes"
);
assert_eq!(attrs.family, font.family);
}
let mut covered = vec![false; alias.len()];
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
for glyph in run.glyphs {
let from = glyph.start.max(start.column);
let to = glyph.end.min(end.column);
if from >= to {
continue;
}
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
let y = position.y + run.line_top;
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
covered[from - start.column..to - start.column].fill(true);
}
}
assert!(
covered.into_iter().all(|seen| seen),
"entire alias has visible shaped glyphs"
);
// This temporary strong upgrade is dropped before the next editor mutation.
}
#[derive(Clone, Copy)]
enum Arm {
Constructor,
Reuse,
}
impl Arm {
fn name(self) -> &'static str {
match self {
Self::Constructor => "with_text",
Self::Reuse => "same_line_reuse",
}
}
fn phases(self) -> [&'static str; 4] {
match self {
Self::Constructor => [
"constructor_replace_origin",
"constructor_next_layout",
"constructor_highlight_draw",
"constructor_combined",
],
Self::Reuse => [
"reuse_replace_origin",
"reuse_next_layout",
"reuse_highlight_draw",
"reuse_combined",
],
}
}
}
fn trial(
fixture: &Fixture,
cursor: text_editor::Cursor,
arm: Arm,
trial: usize,
samples: &mut Samples,
) {
let before = &fixture.bundle.files[ORIGIN];
let after = &fixture.expected_bundle.files[ORIGIN];
let mut editor = Content::with_text(before);
editor.move_to(fixture.cursor);
let mut renderer = ObservedFontLayout::default();
let theme = iced::Theme::Light;
let defaults = renderer::Style::default();
let viewport = Rectangle::with_size(HOST);
let limits = layout::Limits::new(Size::ZERO, HOST);
let mut tree = {
let mut widget = view(&editor, Arc::from(before.as_str()));
let mut tree = Tree::new(widget.as_widget());
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
tree.state
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
.focus();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
tree
};
check_text(&editor, before, fixture.cursor);
check_draw(&renderer, fixture.cursor, OLD);
// No recorder Weak/Strong from setup may perturb replacement ownership.
renderer.drawn = None;
let next_source: Arc<str> = Arc::from(after.as_str());
// Only normal operations and four timestamps occur between start and end.
// Source/model oracles, logging and recorder glyph inspection are outside.
let started = Instant::now();
let replaced = match arm {
Arm::Constructor => {
editor = Content::with_text(after);
true
}
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
};
editor.move_to(cursor);
let replaced_at = Instant::now();
let mut widget = view(&editor, next_source);
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
let laid_out_at = Instant::now();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
let drawn_at = Instant::now();
drop(widget);
assert!(replaced, "reuse must not silently time a fallback/refusal");
check_text(&editor, after, cursor);
check_draw(&renderer, cursor, NEW);
renderer.drawn = None;
for (phase, duration) in arm.phases().into_iter().zip([
replaced_at.duration_since(started),
laid_out_at.duration_since(replaced_at),
drawn_at.duration_since(laid_out_at),
drawn_at.duration_since(started),
]) {
println!(
"{}",
serde_json::json!({
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
"trial": trial, "warmup": trial == 0,
"elapsed_ns": duration.as_nanos().to_string(),
"elapsed_ms": duration.as_secs_f64() * 1000.0,
})
);
if trial != 0 {
samples.0.entry(phase).or_default().push(duration);
}
}
}
#[test]
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
let fixture = Fixture::load();
// Resolve the exact production approval and verify full Project/source once,
// outside all intervals. The actual App Confirm evaluator remains separate.
let cursor = {
let app = fixture.app();
let target = resolve(&app, &app.rename_context());
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
};
println!(
"{}",
serde_json::json!({
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
})
);
let mut samples = Samples::default();
for index in 0..=5 {
let arms = if index % 2 == 0 {
[Arm::Constructor, Arm::Reuse]
} else {
[Arm::Reuse, Arm::Constructor]
};
println!(
"{}",
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
);
for arm in arms {
trial(&fixture, cursor, arm, index, &mut samples);
}
}
samples.report();
}
@@ -1,347 +0,0 @@
//! Manual timing through the real Document and App paths. No disk writes,
//! executor polling, layout, GPU work or first-paint measurement occurs here.
//! Register as a test child of app::source_rename to use its actual worker helpers.
use super::*;
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::{
collections::BTreeMap,
path::PathBuf,
time::{Duration, Instant},
};
#[cfg(debug_assertions)]
#[path = "source_rename_layout_timing_tests.rs"]
mod layout_timing_tests;
const OLD: &str = "oa_restore_work";
const NEW: &str = "oa_restore_pressure";
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
const ORIGIN: &str = "traceability.arcadia";
struct Fixture {
bundle: SourceBundle,
project: Project,
expected_bundle: SourceBundle,
expected_project: Project,
target: Id,
cursor: text_editor::Cursor,
}
impl Fixture {
fn load() -> Self {
// The optional entry selects a parent-pinned copy of this exact fixture,
// not an arbitrary performance generator or a mutable GUI destination.
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/release1/project.arcadia")
});
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
let bundle = loaded.reachable_bundle();
assert_eq!(
bundle.files.len(),
30,
"use the preserved release1 walkthrough"
);
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
assert_eq!(
document.bundle(),
&bundle,
"fixture must need no stabilization/migration"
);
assert!(!document.dirty());
let project = document.project().clone();
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
assert_eq!(project.elements[&target].alias, OLD);
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
let mut expected_project = project.clone();
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
// Independent literal source oracle, authored before running the rename.
// Do not replace the alias substring in filenames or the trace's alias.
let mut expected_bundle = bundle.clone();
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
assert_eq!(
declarations.matches("oa_activity oa_restore_work ").count(),
1
);
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
*declarations = declarations
.replace(
"oa_activity oa_restore_work ",
"oa_activity oa_restore_pressure ",
)
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
let source = &bundle.files[ORIGIN];
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
let position = |offset| {
let prefix = &source[..offset];
text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix
.rsplit_once('\n')
.map_or(prefix.len(), |(_, tail)| tail.len()),
}
};
let cursor = text_editor::Cursor {
position: position(start),
selection: Some(position(start + OLD.len())),
};
assert!(nav::exact_cursor(source, cursor));
println!(
"{}",
serde_json::json!({
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
"identities": project.identities().len(), "active_file": ORIGIN,
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
})
);
Self {
bundle,
project,
expected_bundle,
expected_project,
target,
cursor,
}
}
fn app(&self) -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = ORIGIN.into();
app.selected = app.project().id;
app.page = Page::Text;
app.sync();
app.source_editor.move_to(self.cursor);
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
assert!(app.rename_admission().is_ok());
app
}
fn check_candidate(&self, prepared: &PreparedAliasRename) {
assert!(prepared.changed());
assert_eq!(prepared.before().bundle, self.bundle);
assert_eq!(prepared.before().decoded.project, self.project);
assert_eq!(prepared.after().bundle, self.expected_bundle);
assert_eq!(prepared.after().decoded.project, self.expected_project);
}
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
assert!(
std::ptr::eq(app.document.compiled(), candidate_address),
"adopt exact candidate allocation"
);
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
assert_eq!(app.document.revision(), 1);
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
assert!(app.document.undo());
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert!(
!app.document.dirty(),
"live known saved baseline remains authoritative"
);
assert!(
!app.document.can_undo(),
"exactly one preceding model transaction"
);
assert!(app.document.redo());
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
}
}
#[derive(Default)]
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
impl Samples {
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
let started = Instant::now();
let result = action();
let elapsed = started.elapsed();
// Printing, assertions and dropping the returned value are outside this
// interval. Full Confirm still includes production's own local drops.
println!(
"{}",
serde_json::json!({
"record": "timing", "phase": phase, "trial": trial,
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
})
);
if trial != 0 {
self.0.entry(phase).or_default().push(elapsed);
}
result
}
fn report(mut self) {
for (phase, values) in &mut self.0 {
assert_eq!(values.len(), 5);
values.sort();
println!(
"{}",
serde_json::json!({
"record": "summary", "phase": phase, "samples": values.len(),
"min_ms": values[0].as_secs_f64() * 1000.0,
"median_ms": values[2].as_secs_f64() * 1000.0,
"max_ms": values[4].as_secs_f64() * 1000.0
})
);
}
}
}
fn resolve(app: &App, context: &Context) -> Target {
let snapshot = app
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
.unwrap()
.capture()
.unwrap();
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
resolve_target(&snapshot, context, offset).unwrap()
}
fn active_job(app: &App) -> Job {
let form = app.source_rename.form.as_ref().unwrap();
let id = form.pending.unwrap();
assert_eq!(app.source_rename.in_flight, Some(id));
Job {
id,
invocation: form.invocation,
generation: form.generation,
context: form.context.clone(),
}
}
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let target = resolve(&app, &app.rename_context());
assert_eq!(target.id, fixture.target);
let request = samples
.time("document_request_mint", trial, || {
app.document.alias_rename_request(target.id, NEW.into())
})
.unwrap();
let prepared = samples
.time("worker_document_prepare", trial, || request.prepare())
.unwrap();
fixture.check_candidate(&prepared);
let approval = samples
.time("worker_preview_and_origin", trial, || {
prepare_approval(prepared, &target, ORIGIN)
})
.unwrap();
assert_eq!(approval.preview.files().len(), 2);
assert_eq!(approval.preview.changed_line_count(), 6);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
// Same-destination save completion is a legitimate live update after Preview.
app.document.mark_saved(app.document.snapshot());
let changed = samples
.time("document_guarded_commit", trial, || {
app.document.commit_alias_rename(&approval.prepared)
})
.unwrap();
assert!(changed);
samples.time("app_sync", trial, || app.sync());
fixture.check_adopted(&mut app, candidate_address);
}
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let begin = app.source_rename_message();
// These Tasks are deliberately not polled: deterministic worker helpers
// supply the same results through the real readiness messages below.
drop(app.update(begin));
let target_job = active_job(&app);
let target = Arc::new(resolve(&app, &target_job.context));
assert_eq!(target.id, fixture.target);
drop(app.update(Message::SourceRenameReady(Completion {
job: target_job,
payload: Payload::Target(Ok(target.clone())),
})));
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
let generation = app.source_rename.form.as_ref().unwrap().generation;
drop(app.update(Message::SourceRename(Event::Preview(
invocation, generation,
))));
let preview_job = active_job(&app);
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
drop(target);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
let expected_cursor = approval.cursor;
let approval_id = approval.id;
drop(app.update(Message::SourceRenameReady(Completion {
job: preview_job,
payload: Payload::Preview(Ok(approval)),
})));
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
approval_id
);
app.document.mark_saved(app.document.snapshot());
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
samples.time("confirm_returned_task_drop_without_polling", trial, || {
drop(task)
});
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
assert!(app.source_rename_undo_message().is_some());
assert_eq!(app.source_editor.cursor(), expected_cursor);
fixture.check_adopted(&mut app, candidate_address);
}
#[test]
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
let fixture = Fixture::load();
let mut samples = Samples::default();
for trial in 0..=5 {
println!(
"{}",
serde_json::json!({ "record": "order", "trial": trial,
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
);
if trial % 2 == 0 {
separate_phases(&fixture, &mut samples, trial);
whole_confirm(&fixture, &mut samples, trial);
} else {
whole_confirm(&fixture, &mut samples, trial);
separate_phases(&fixture, &mut samples, trial);
}
}
samples.report();
}
+27 -85
View File
@@ -52,7 +52,7 @@ impl App {
}
}
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
let files: Vec<_> = self
.source_workspace
.files
@@ -83,14 +83,13 @@ impl App {
})
}
pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
snapshot.key() == self.source_key()
&& self.with_source_input(|input| snapshot.matches(input))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.source_rename.open()
&& !self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
@@ -98,7 +97,6 @@ impl App {
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.model_menu.is_none()
&& self.text_workspace.menu().is_none()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
@@ -114,12 +112,6 @@ impl App {
))
}
/// The reviewed diagram-query union is not integrated into this source yet.
/// Its eventual adapter must return the peer's captured chooser message.
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
None
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
@@ -152,10 +144,6 @@ impl App {
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
// This ticket restores focus to the live editor, not a saved cursor.
// Iced's Cursor omits Word/Line selection kind; moving back to that
// lossy value would collapse an actual word/line selection.
self.source_jump = None;
}
}
@@ -185,14 +173,13 @@ impl App {
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
);
}
// A valid ticket with no jump is focus-only. All intentional navigation
// still takes the cursor branch above, including explicit line selection.
nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
)
Task::none()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
@@ -201,30 +188,20 @@ impl App {
}
match event {
nav::SymbolEvent::Close => {
return self.show_text_drawer(None);
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.
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(),
},
);
}
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
..
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
@@ -262,8 +239,7 @@ impl App {
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.text_workspace
.show_drawer(Some(text_workspace::DrawerTab::References));
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
@@ -284,7 +260,7 @@ impl App {
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
@@ -324,12 +300,9 @@ 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;
let worker = Task::perform(
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
@@ -360,14 +333,7 @@ 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> {
@@ -376,11 +342,11 @@ impl App {
return Task::none();
}
self.source_symbols.in_flight = None;
let wanted_navigation = self
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current == request)
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
@@ -390,33 +356,14 @@ impl App {
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
let wanted_display = self
.source_symbols
.display_request
.as_ref()
.is_some_and(|current| current == &request)
&& request.stamp.snapshot == self.source_key();
if !wanted_display {
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results))
if self.source_snapshot_matches(&snapshot)
&& results.stamp == request.stamp
&& results.action == request.action =>
{
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
self.text_workspace.install_reference_files(
results.stamp,
results
.definitions
.iter()
.map(|row| row.path.clone())
.chain(results.references.iter().map(|row| row.path.clone())),
);
if wanted_navigation
&& results.disposition == query::Disposition::Direct
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
@@ -451,9 +398,6 @@ impl App {
Some("That source location is no longer available. Run navigation again.".into());
return;
}
// The destination is admitted. Capture effective Word/Line ranges only
// now, when this editor is actually about to leave its current position.
let departure = nav::selection::capture(&mut self.source_editor);
if let Some(buffer) = self
.source_workspace
.files
@@ -463,7 +407,7 @@ impl App {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: departure,
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
@@ -473,7 +417,8 @@ impl App {
}
self.source_symbols.back.push(origin);
}
self.source_workspace.switch(path, departure);
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
@@ -488,7 +433,7 @@ impl App {
return;
};
let unchanged = origin.generation == buffer.generation();
let mut cursor = if unchanged {
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
@@ -502,11 +447,8 @@ impl App {
);
return;
}
let departure = nav::selection::capture(&mut self.source_editor);
if !unchanged && origin.path == self.source_workspace.active {
cursor = departure;
}
self.source_workspace.switch(&origin.path, departure);
self.source_workspace
.switch(&origin.path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
self.source_symbols.notice =
File diff suppressed because it is too large Load Diff
-646
View File
@@ -1,646 +0,0 @@
//! Text-only presentation and captured command envelopes. Source/model authority
//! remains in the existing App controllers; dispatch this envelope before the
//! generic source_interaction invalidation in App::update.
use super::{App, Message};
use crate::{context_menu, source_navigation as nav};
use iced::{
Point, Task,
widget::{self, text_editor::Cursor},
};
use nav::query::{QueryStamp, SnapshotKey};
use oa_core::{Id, source_project::SourceLocation};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawerTab {
References,
Problems,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Find,
GoToLine,
Definition,
References,
RenameAlias,
Back,
UndoText,
RedoText,
UndoModel,
RedoModel,
UndoRename,
RevertFile,
ShowProblems,
ShowStatus,
ShowDiagramUsages,
}
impl Command {
fn shortcut(self) -> Option<&'static str> {
match self {
Self::Find => Some("Ctrl F"),
Self::GoToLine => Some("Ctrl G"),
Self::Definition => Some("F12"),
Self::References => Some("Shift F12"),
Self::RenameAlias => Some("F2"),
Self::Back => Some("Alt ←"),
Self::UndoText => Some("Ctrl Z"),
Self::RedoText => Some("Ctrl Y"),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
Self::Find => "Find in file",
Self::GoToLine => "Go to line",
Self::Definition => "Go to definition",
Self::References => "Find references",
Self::RenameAlias => "Rename source alias",
Self::Back => "Back in source",
Self::UndoText => "Undo text edit",
Self::RedoText => "Redo text edit",
Self::UndoModel => "Undo model change",
Self::RedoModel => "Redo model change",
Self::UndoRename => "Undo source rename",
Self::RevertFile => "Revert current file",
Self::ShowProblems => "Source problems",
Self::ShowStatus => "Source status",
Self::ShowDiagramUsages => "Show diagram usages",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommandContext {
document: Id,
document_revision: u64,
source: SnapshotKey,
path: String,
cursor: Cursor,
interaction: Id,
}
#[derive(Debug, Clone)]
pub struct CommandRequest {
context: CommandContext,
command: Command,
}
#[derive(Debug, Clone)]
pub struct MenuRequest {
context: CommandContext,
anchor: Point,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiagnosticPublication {
generation: Id,
source: SnapshotKey,
}
impl DiagnosticPublication {
pub fn generation(self) -> Id {
self.generation
}
pub fn source(self) -> SnapshotKey {
self.source
}
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
DiagnosticTarget {
publication: self,
visit,
location,
}
}
}
#[derive(Debug, Clone)]
pub struct DiagnosticTarget {
publication: DiagnosticPublication,
visit: Id,
location: SourceLocation,
}
#[derive(Debug, Clone)]
pub enum Event {
ShowDrawer(DrawerTab),
HideDrawer,
ResizeDrawer(f32),
ToggleFile {
stamp: QueryStamp,
visit: Id,
path: String,
},
ReferenceScrolled {
stamp: QueryStamp,
visit: Id,
y: f32,
},
Command(CommandRequest),
OpenMenu(MenuRequest),
DismissMenu(Id),
Invoke {
token: Id,
command: Command,
},
Diagnostic(DiagnosticTarget),
}
#[derive(Debug, Clone)]
struct FrozenEntry {
command: Command,
action: Option<Action>,
}
#[derive(Debug, Clone)]
enum Action {
Dispatch(Box<Message>),
Drawer(DrawerTab),
}
#[derive(Debug, Clone)]
pub struct Menu {
token: Id,
popup_token: u64,
anchor: Point,
context: CommandContext,
entries: Vec<FrozenEntry>,
}
impl Menu {
pub fn token(&self) -> Id {
self.token
}
}
#[derive(Debug)]
pub struct ReferencePresentation {
stamp: Option<QueryStamp>,
origin: String,
files: BTreeSet<String>,
installed: bool,
collapsed_files: BTreeSet<String>,
scroll_y: f32,
}
impl Default for ReferencePresentation {
fn default() -> Self {
Self {
stamp: None,
origin: String::new(),
files: BTreeSet::new(),
installed: false,
collapsed_files: BTreeSet::new(),
scroll_y: 0.,
}
}
}
impl ReferencePresentation {
pub fn stamp(&self) -> Option<QueryStamp> {
self.stamp
}
pub fn scroll_y(&self) -> f32 {
self.scroll_y
}
pub fn collapsed(&self, path: &str) -> bool {
self.collapsed_files.contains(path)
}
}
#[derive(Debug)]
pub struct State {
drawer: Option<DrawerTab>,
drawer_visit: Id,
drawer_height: f32,
references: ReferencePresentation,
menu: Option<Menu>,
diagnostic_publication: Option<DiagnosticPublication>,
}
impl Default for State {
fn default() -> Self {
Self {
drawer: None,
drawer_visit: Id::new_v4(),
drawer_height: 180.,
references: ReferencePresentation::default(),
menu: None,
diagnostic_publication: None,
}
}
}
impl State {
pub fn drawer_visit(&self) -> Id {
self.drawer_visit
}
pub fn reference_scroll_id(&self) -> widget::Id {
widget::Id::from(format!("text-references-{}", self.drawer_visit))
}
pub fn drawer(&self) -> Option<DrawerTab> {
self.drawer
}
pub fn drawer_height(&self) -> f32 {
self.drawer_height
}
pub fn references(&self) -> &ReferencePresentation {
&self.references
}
pub fn menu(&self) -> Option<&Menu> {
self.menu.as_ref()
}
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
self.diagnostic_publication
}
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
let publication = DiagnosticPublication {
generation: Id::new_v4(),
source,
};
self.diagnostic_publication = Some(publication);
publication
}
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn dismiss_menu(&mut self) {
self.menu = None;
}
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
self.references = ReferencePresentation {
stamp: Some(stamp),
origin,
..ReferencePresentation::default()
};
self.show_drawer(Some(DrawerTab::References));
}
/// Install presentation groups only for the current completed query. Repeated
/// delivery of the same completion cannot undo the user's group choices.
pub fn install_reference_files(
&mut self,
stamp: QueryStamp,
paths: impl IntoIterator<Item = String>,
) {
let refs = &mut self.references;
if refs.stamp != Some(stamp) || refs.installed {
return;
}
refs.files = paths.into_iter().collect();
refs.collapsed_files = refs.files.clone();
let expanded = if refs.files.contains(&refs.origin) {
Some(refs.origin.clone())
} else {
refs.files.first().cloned()
};
if let Some(path) = expanded {
refs.collapsed_files.remove(&path);
}
refs.installed = true;
}
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
self.drawer = drawer;
self.drawer_visit = Id::new_v4();
}
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
let refs = &mut self.references;
if self.drawer != Some(DrawerTab::References)
|| self.drawer_visit != visit
|| refs.stamp != Some(stamp)
|| !refs.files.contains(&path)
{
return false;
}
if !refs.collapsed_files.remove(&path) {
refs.collapsed_files.insert(path);
}
// Collapsing a group can replace a pressed row at the same position.
self.drawer_visit = Id::new_v4();
true
}
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
let refs = &mut self.references;
if self.drawer == Some(DrawerTab::References)
&& refs.stamp == Some(stamp)
&& self.drawer_visit == visit
&& y.is_finite()
&& y >= 0.
{
refs.scroll_y = y;
}
}
}
impl App {
fn text_command_context(&self) -> CommandContext {
CommandContext {
document: self.canvas_document,
document_revision: self.document.revision(),
source: self.source_key(),
path: self.source_workspace.active.clone(),
cursor: self.source_editor.cursor(),
interaction: self.source_symbols.interaction,
}
}
pub fn text_command_message(&self, command: Command) -> Message {
Message::TextWorkspace(Event::Command(CommandRequest {
context: self.text_command_context(),
command,
}))
}
pub fn text_menu_message(&self, anchor: Point) -> Message {
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
context: self.text_command_context(),
anchor,
}))
}
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
let menu = self.text_workspace.menu.as_ref()?;
Some(context_menu::Menu {
token: menu.popup_token,
anchor: menu.anchor,
entries: menu
.entries
.iter()
.flat_map(|entry| {
let mut rows = Vec::with_capacity(2);
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
rows.push(context_menu::Entry::separator());
}
rows.push(context_menu::Entry::Action {
label: entry.command.label().into(),
shortcut: entry.command.shortcut().map(str::to_owned),
message: entry.action.as_ref().map(|_| {
Message::TextWorkspace(Event::Invoke {
token: menu.token(),
command: entry.command,
})
}),
});
rows
})
.collect(),
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
})
}
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
let publication = self.text_workspace.diagnostic_publication?;
(publication.source == self.source_key()
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
.then(|| {
Message::TextWorkspace(Event::Diagnostic(
publication.target(self.text_workspace.drawer_visit(), location),
))
})
}
pub(crate) fn source_annotation_snapshot(
&self,
) -> Option<crate::source_annotations::Snapshot<'_>> {
let publication = self.text_workspace.diagnostic_publication?;
let buffer = self
.source_workspace
.files
.get(&self.source_workspace.active)?;
if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() {
return None;
}
Some(crate::source_annotations::Snapshot {
publication: publication.generation,
source: self.source_snapshot.clone(),
path: &self.source_workspace.active,
diagnostics: &self.source_diagnostics,
})
}
fn text_command_action(&self, command: Command) -> Option<Action> {
// Menu has been taken before dispatch-time admission. This intentionally
// uses navigation admission, not rename admission, for Find/text history.
if !self.source_navigation_available() {
return None;
}
let message =
match command {
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
Command::Definition => self
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
Command::References => self
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
Command::RenameAlias => {
self.rename_admission().ok()?;
self.source_rename_message()
}
Command::Back => {
if !self
.source_symbols
.back
.iter()
.any(|entry| entry.session == self.source_workspace.session())
{
return None;
}
self.source_symbol_message(nav::SymbolEvent::Back)
}
Command::UndoText => Message::SourceUndo,
Command::RedoText => Message::SourceRedo,
Command::UndoModel | Command::RedoModel => {
if self.source_dirty() || self.draft_dirty() {
return None;
}
if command == Command::UndoModel {
if !self.document.can_undo() {
return None;
}
Message::Undo
} else {
if !self.document.can_redo() {
return None;
}
Message::Redo
}
}
Command::UndoRename => self.source_rename_undo_message()?,
Command::RevertFile => Message::RevertSource,
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
};
Some(Action::Dispatch(Box::new(message)))
}
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::OpenMenu(request) => {
if request.context != self.text_command_context()
|| !self.source_navigation_available()
|| self.text_workspace.menu.is_some()
|| !request.anchor.x.is_finite()
|| !request.anchor.y.is_finite()
{
return Task::none();
}
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
return Task::none();
};
self.source_interaction();
let commands = [
Command::Find,
Command::GoToLine,
Command::Definition,
Command::References,
Command::RenameAlias,
Command::Back,
Command::UndoText,
Command::RedoText,
Command::UndoRename,
Command::UndoModel,
Command::RedoModel,
Command::RevertFile,
Command::ShowProblems,
Command::ShowStatus,
Command::ShowDiagramUsages,
];
let entries = commands
.into_iter()
.filter_map(|command| {
let action = self.text_command_action(command);
if command == Command::ShowDiagramUsages && action.is_none() {
None
} else {
Some(FrozenEntry { command, action })
}
})
.collect();
self.menu_sequence = popup_token;
self.text_workspace.menu = Some(Menu {
token: Id::new_v4(),
popup_token,
anchor: request.anchor,
context: self.text_command_context(),
entries,
});
}
Event::DismissMenu(token) => {
if self
.text_workspace
.menu
.as_ref()
.is_some_and(|menu| menu.token == token)
{
self.text_workspace.menu = None;
self.source_interaction();
}
}
Event::Invoke { token, command } => {
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
menu.token == token && menu.context == self.text_command_context()
}) {
return Task::none();
}
// Validate before closing; consume this invocation only once.
// Never resolve a frozen row against a newly generated menu.
let menu = self.text_workspace.menu.take().unwrap();
let Some(action) = menu
.entries
.into_iter()
.find(|entry| entry.command == command)
.and_then(|entry| entry.action)
else {
return Task::none();
};
if self.text_command_action(command).is_none() {
return Task::none();
}
return self.dispatch_text_action(action);
}
Event::Command(request) => {
if request.context != self.text_command_context() {
return Task::none();
}
if let Some(action) = self.text_command_action(request.command) {
return self.dispatch_text_action(action);
}
}
Event::ShowDrawer(tab) => {
if !self.source_navigation_available() {
return Task::none();
}
return self.show_text_drawer(Some(tab));
}
Event::HideDrawer => {
if !self.source_navigation_available() {
return Task::none();
}
return self.show_text_drawer(None);
}
Event::ResizeDrawer(height) => {
if self.source_navigation_available() && height.is_finite() {
// The view applies the measured editor-allocation bound.
let height = height.clamp(180., 1200.);
if height != self.text_workspace.drawer_height {
self.source_interaction();
self.text_workspace.drawer_height = height;
}
}
}
Event::ToggleFile { stamp, visit, path } => {
if self.source_navigation_available()
&& self.text_workspace.toggle_file(stamp, visit, path)
{
self.source_interaction();
}
}
Event::ReferenceScrolled { stamp, visit, y } => {
// Passive geometry must neither invalidate pending navigation
// nor reset the editor identity, even while commands are blocked.
self.text_workspace.reference_scrolled(stamp, visit, y);
}
Event::Diagnostic(target) => {
if !self.source_navigation_available()
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|| self.text_workspace.drawer_visit() != target.visit
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|| target.publication.source != self.source_key()
|| !self
.source_workspace
.files
.get(&target.location.path)
.is_some_and(|file| valid_span(&file.text, &target.location))
{
return Task::none();
}
self.source_interaction();
self.jump_to_source(target.location);
}
}
Task::none()
}
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
match action {
Action::Dispatch(message) => self.update(*message),
Action::Drawer(tab) => self.show_text_drawer(Some(tab)),
}
}
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
self.source_interaction();
self.text_workspace.show_drawer(drawer);
if drawer == Some(DrawerTab::References) {
let refs = self.text_workspace.references();
widget::operation::scroll_to(
self.text_workspace.reference_scroll_id(),
widget::scrollable::AbsoluteOffset {
x: 0.,
y: refs.scroll_y(),
},
)
} else {
Task::none()
}
}
}
fn valid_span(text: &str, location: &SourceLocation) -> bool {
let span = location.span;
span.start <= span.end
&& span.end <= text.len()
&& text.is_char_boundary(span.start)
&& text.is_char_boundary(span.end)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod lifecycle_tests;
#[cfg(all(test, debug_assertions))]
mod focus_selection_tests;
@@ -1,480 +0,0 @@
//! The mounted CPU renderer and App use the same cosmic-text editor engine, but
//! Iced's Content is parameterized by renderer and cannot be shared between them.
//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret
//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions.
//! App's own Content is never replaced or cursor-normalized after selection. This
//! tests the actual controller/content boundary without a window or GPU; native
//! replay remains the check of the complete production renderer/layout path.
use super::*;
use crate::app::Page;
use crate::document::Document;
use crate::source_workspace::{LIMITS, Workspace};
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, Shell, layout, widget as core_widget};
use iced::futures::StreamExt;
use iced::keyboard::{self, Key, Modifiers};
use iced::widget::{button, container, row, text_editor, text_input};
use iced::{Element, Event, Font, Size, mouse};
use oa_core::{Project, source_project::SourceBundle};
use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering};
const SOURCE: &str = concat!(
"// oa_contact tail\r\n",
"arcadia 7;\r\n",
"project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n",
);
const HOST: Size = Size::new(620., 260.);
fn fixture() -> App {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
};
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
app.show_source_file(false);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app
}
fn ui<'a>(
app: &App,
content: &'a text_editor::Content<FontLayout>,
) -> Element<'a, Message, iced::Theme, FontLayout> {
let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open {
row![
text_input("Find", "")
.id("source-find")
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
container(button("Close").on_press(app.source_find_close_message()))
.id("selection-find-close"),
]
.into()
} else {
widget::Space::new().into()
};
// Column::push drops zero-height children. Keep both paths mounted even
// while Find is hidden, as the production retained-slot layout does.
widget::Column::from_vec(vec![
container(find)
.height(if app.source_navigation.open { 32. } else { 0. })
.into(),
widget::text_editor(content)
.id(app.source_editor_id())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(iced::Fill)
.on_action(Message::SourceEdit)
.into(),
])
.width(iced::Fill)
.height(iced::Fill)
.into()
}
struct Mounted {
content: text_editor::Content<FontLayout>,
tree: core_widget::Tree,
}
// Operations follow widget-provided traversal instead of assuming a fixed
// relationship between layout nodes, transparent containers and state trees.
struct Inspection {
editor: core_widget::Id,
focused: Option<bool>,
close_bounds: Option<iced::Rectangle>,
}
impl core_widget::Operation for Inspection {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
operate(self);
}
fn focusable(
&mut self,
id: Option<&core_widget::Id>,
_: iced::Rectangle,
state: &mut dyn core_widget::operation::Focusable,
) {
if id == Some(&self.editor) {
assert!(
self.focused.replace(state.is_focused()).is_none(),
"duplicate editor ID"
);
}
}
fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) {
if id == Some(&core_widget::Id::new("selection-find-close")) {
assert!(
self.close_bounds.replace(bounds).is_none(),
"duplicate Close target ID"
);
}
}
}
impl Mounted {
fn new() -> Self {
Self {
content: text_editor::Content::with_text(SOURCE),
tree: core_widget::Tree::empty(),
}
}
fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec<Message> {
let mut view = ui(app, &self.content);
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
let mut messages = Vec::new();
for (event, cursor) in events {
let mut shell = Shell::new(&mut messages);
view.as_widget_mut().update(
&mut self.tree,
event,
Layout::new(&node),
*cursor,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::with_size(HOST),
);
if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) {
assert!(
shell.is_event_captured(),
"mounted input must be handled: {event:?}"
);
}
}
messages
}
fn operation(&mut self, app: &App, mut op: Box<dyn core_widget::Operation>) {
let mut view = ui(app, &self.content);
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
loop {
view.as_widget_mut().operate(
&mut self.tree,
Layout::new(&node),
&FontLayout,
op.as_mut(),
);
match op.finish() {
core_widget::operation::Outcome::Chain(next) => op = next,
core_widget::operation::Outcome::None => break,
core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"),
}
}
}
async fn task(&mut self, app: &App, task: Task<Message>) {
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(op) = action else {
panic!("expected a widget operation")
};
self.operation(app, op);
}
}
}
fn inspect(&mut self, app: &App) -> Inspection {
let mut inspection = Inspection {
editor: app.source_editor_id(),
focused: None,
close_bounds: None,
};
let mut view = ui(app, &self.content);
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut().operate(
&mut self.tree,
Layout::new(&node),
&FontLayout,
&mut inspection,
);
inspection
}
fn editor_focused(&mut self, app: &App) -> bool {
self.inspect(app)
.focused
.expect("exactly one editor with the requested ID")
}
fn close_message(&mut self, app: &App, escape: bool) -> Message {
if escape {
let event = Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(keyboard::key::Named::Escape),
modified_key: Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
});
assert!(
self.events(app, &[(event, mouse::Cursor::Unavailable)])
.is_empty()
);
// The existing subscription tests cover the captured-Escape bridge.
// Deliver its App message after this actual input captured the key.
Message::Escape
} else {
let center = self
.inspect(app)
.close_bounds
.expect("the mounted Find Close target exists")
.center();
let cursor = mouse::Cursor::Available(center);
let mut messages = self.events(
app,
&[
(
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
cursor,
),
(
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
cursor,
),
],
);
assert_eq!(messages.len(), 1);
let close = messages.remove(0);
assert!(matches!(close, Message::SourceFindClose(..)));
close
}
}
fn select(&mut self, app: &mut App, line: bool) {
// One mounted update batch avoids delays between Iced's consecutive-click
// timestamps; content actions are applied in their emitted order below.
let at = mouse::Cursor::Available(Point::new(65., 25.));
let events = (0..if line { 3 } else { 2 })
.flat_map(|_| {
[
(
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
at,
),
(
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
at,
),
]
})
.collect::<Vec<_>>();
let messages = self.events(app, &events);
assert_eq!(messages.len(), if line { 3 } else { 2 });
for (index, message) in messages.into_iter().enumerate() {
let Message::SourceEdit(action) = message else {
panic!("editor action")
};
if index == 0 {
assert!(matches!(action, text_editor::Action::Click(_)));
self.content.perform(action);
let caret = self.content.cursor();
assert_eq!(caret.position.line, 0);
assert!(
(4..13).contains(&caret.position.column),
"click must hit inside oa_contact: {caret:?}"
);
// App's GPU-typed Content has no public layout API. Copy only the
// initial hit-tested caret before any Word/Line selection exists.
app.source_editor.move_to(caret);
} else {
assert_eq!(
action,
if index == 1 {
text_editor::Action::SelectWord
} else {
text_editor::Action::SelectLine
}
);
self.content.perform(action.clone());
let _ = app.update(Message::SourceEdit(action));
}
}
assert_eq!(
self.content.selection().as_deref(),
Some(if line {
"// oa_contact tail"
} else {
"oa_contact"
})
);
assert_eq!(app.source_editor.selection(), self.content.selection());
assert!(self.editor_focused(app));
}
fn type_replacement(&mut self, app: &mut App) {
let event = Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Character("x".into()),
modified_key: Key::Character("x".into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: Some("x".into()),
repeat: false,
});
let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]);
assert_eq!(messages.len(), 1);
let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else {
panic!("typed edit")
};
assert_eq!(
action,
text_editor::Action::Edit(text_editor::Edit::Insert('x'))
);
self.content.perform(action.clone());
let _ = app.update(Message::SourceEdit(action));
}
}
#[tokio::test]
async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() {
for line in [false, true] {
for escape in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let mut mounted = Mounted::new();
mounted.select(&mut app, line);
let selected = app.source_editor.selection();
let cursor = app.source_editor.cursor();
// Word/Line selection has coincident raw endpoints. Merely checking
// cursor equality after a move_to roundtrip cannot detect the defect.
assert_eq!(cursor.selection, Some(cursor.position));
let task = app.update(Message::SourceNavigation(nav::Event::Open(false)));
mounted.task(&app, task).await;
assert!(!mounted.editor_focused(&app));
assert_eq!(app.source_editor.selection(), selected);
let close = mounted.close_message(&app, escape);
let _ = app.update(close);
let ticket = app
.source_reveal_ticket()
.expect("guarded restoration ticket");
let task = app.update(Message::SourceViewport(Some(ticket)));
mounted.task(&app, task).await;
assert!(mounted.editor_focused(&app));
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection(),
selected,
"selected bytes must survive focus restoration"
);
assert_eq!(app.source_editor.text(), SOURCE);
mounted.type_replacement(&mut app);
let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1);
assert_eq!(app.source_editor.text(), expected);
assert_eq!(app.source_workspace.text(), expected);
assert_eq!(mounted.content.text(), expected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() {
for line in [false, true] {
for old_roundtrip in [false, true] {
let mut app = fixture();
let mut mounted = Mounted::new();
mounted.select(&mut app, line);
let selected = app.source_editor.selection().unwrap();
let interior = app.source_editor.cursor();
if old_roundtrip {
app.source_editor.move_to(interior);
assert_ne!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
}
mounted.type_replacement(&mut app);
let expected = if old_roundtrip {
let mut text = SOURCE.to_owned();
text.insert(interior.position.column, 'x');
text
} else {
SOURCE.replacen(&selected, "x", 1)
};
assert_eq!(app.source_editor.text(), expected);
assert_eq!(app.source_workspace.text(), expected);
assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1));
}
}
}
#[tokio::test]
async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() {
for later_raw_input in [false, true] {
let mut app = fixture();
let mut mounted = Mounted::new();
mounted.select(&mut app, false);
let selected = app.source_editor.selection();
let open = app.update(Message::SourceNavigation(nav::Event::Open(false)));
mounted.task(&app, open).await;
let close = app.source_find_close_message();
let _ = app.update(close);
let ticket = app.source_reveal_ticket().unwrap();
let focus = if later_raw_input {
app.update(Message::SourceViewport(Some(ticket)))
} else {
Task::none()
};
let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false)));
mounted.task(&app, reopen).await;
if later_raw_input {
app.source_symbols
.raw_interaction
.fetch_add(1, Ordering::SeqCst);
mounted.task(&app, focus).await;
} else {
let stale = app.update(Message::SourceViewport(Some(ticket)));
mounted.task(&app, stale).await;
}
assert!(app.source_navigation.open);
assert!(!mounted.editor_focused(&app));
assert_eq!(app.source_editor.selection(), selected);
assert_eq!(app.source_editor.text(), SOURCE);
}
}
#[tokio::test]
async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() {
let mut app = fixture();
let mut mounted = Mounted::new();
mounted.select(&mut app, false);
let target = text_editor::Cursor {
position: text_editor::Position { line: 1, column: 3 },
selection: None,
};
// Clear the previous semantic selection as actual show_source_file does when
// moving to another source buffer, then exercise intentional navigation.
app.show_source_file(false);
app.queue_source_reveal(target, true);
let ticket = app.source_reveal_ticket().unwrap();
let task = app.update(Message::SourceViewport(Some(ticket)));
mounted.task(&app, task).await;
assert_eq!(app.source_editor.cursor().position, target.position);
assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;"));
}
@@ -1,438 +0,0 @@
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
//! Returned App worker tasks are not polled; completions use the normal captured
//! query input and are delivered deterministically through App::update.
use super::*;
use crate::app::{Page, read_snapshot, write_snapshot};
use crate::document::Document;
use crate::source_workspace::{LIMITS, Workspace};
use oa_core::{
Project,
language::{Diagnostic, Span},
source_project::{SourceBundle, SourceDiagnostic},
validation::Severity,
};
use std::{collections::BTreeMap, path::Path, sync::Arc};
const VALID: &str = concat!(
"arcadia 7;\r\n",
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
"}\r\n",
);
fn fixture() -> App {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
};
let mut app = App::with_project(Project::empty());
// with_project uses in-memory Home metadata and no recovery directory.
assert!(app.recovery.directory.is_none());
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
app.show_source_file(false);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app
}
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn save(app: &mut App, path: &Path) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.to_owned(),
app.disk_project.clone(),
)
.unwrap();
send(app, Message::Saved(Ok(Some(receipt))));
}
fn reveal(app: &mut App) {
if let Some(ticket) = app.source_reveal_ticket() {
send(app, Message::SourceViewport(Some(ticket)));
}
}
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
send(app, message);
let request = app.source_symbols.request.clone().expect("admitted query");
assert_eq!(app.source_symbols.in_flight, Some(request.job));
let snapshot = Arc::new(app.with_source_input(|input| {
nav::query::admit_owned(input, nav::query::Limits::default())
.unwrap()
.capture()
.unwrap()
}));
let results = Arc::new(
snapshot
.query(nav::query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action,
})
.unwrap(),
);
nav::Completion {
request,
result: Ok((snapshot, results)),
}
}
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
let text = app.source_workspace.text();
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
let prefix = &text[..start];
let position = widget::text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix.rsplit_once('\n').unwrap().1.len(),
};
app.source_editor.move_to(Cursor {
position,
selection: None,
});
let ready = query(app, nav::query::Action::Definition);
send(app, Message::SourceSymbolsReady(ready));
reveal(app);
assert_eq!(app.source_symbols.back.len(), 1);
let ready = query(app, nav::query::Action::References);
send(app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
assert!(app.source_symbols.display_request.is_some());
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
if pending {
Some(query(app, nav::query::Action::Definition))
} else {
None
}
}
fn warning(app: &App) -> SourceDiagnostic {
let source = app.source_workspace.text();
let primary = source.find("model").unwrap();
let related = source.find("assess").unwrap();
SourceDiagnostic {
path: app.source_workspace.active.clone(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "lifecycle_warning".into(),
object_id: None,
field: None,
line: 2,
column: 9,
message: "Retained warning with a related source location".into(),
span: Span {
start: primary,
end: primary + 5,
},
},
related: vec![SourceLocation {
path: app.source_workspace.active.clone(),
span: Span {
start: related,
end: related + 6,
},
}],
}
}
fn warning_messages(app: &App) -> [Message; 2] {
let warning = &app.source_diagnostics[0];
[
app.text_diagnostic_message(SourceLocation {
path: warning.path.clone(),
span: warning.diagnostic.span,
})
.unwrap(),
app.text_diagnostic_message(warning.related[0].clone())
.unwrap(),
]
}
fn assert_cleared(app: &App, job: Option<Id>) {
assert_eq!(app.source_symbols.in_flight, job);
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
assert!(app.text_workspace.references().stamp().is_none());
}
#[test]
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let mut app = fixture();
save(&mut app, &first_path);
let ready = seed_navigation(&mut app, true).unwrap();
let job = ready.request.job;
app.publish_source_diagnostics(vec![warning(&app)]);
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let old = warning_messages(&app);
let warnings = app.source_diagnostics.clone();
let publication = app.text_workspace.diagnostic_publication().unwrap();
let source = app.source_key();
let accepted = app.document.snapshot();
let revision = app.document.revision();
let raw = app.source_workspace.bundle();
save(&mut app, &second_path);
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
assert_ne!(app.source_key().workspace, source.workspace);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_eq!(app.document.revision(), revision);
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
assert_eq!(app.source_diagnostics, warnings);
let current = app
.text_workspace
.diagnostic_publication()
.expect("identity-only save retained current warnings");
assert_eq!(current.source(), app.source_key());
assert_ne!(current.generation(), publication.generation());
assert_cleared(&app, Some(job));
for message in old {
let interaction = app.source_symbols.interaction;
send(&mut app, message);
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
}
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
for message in warning_messages(&app) {
send(&mut app, message);
assert!(
app.source_symbols.reveal.is_some(),
"fresh primary and related warnings remain actionable"
);
reveal(&mut app);
}
let cursor = app.source_editor.cursor();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
{
for pending in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.canonicalize()
.unwrap()
.join("broken.arcadia");
let invalid = format!(
"{} include \"missing.arcadia\";\r\n}}\r\n",
VALID.strip_suffix("}\r\n").unwrap()
);
std::fs::write(&path, &invalid).unwrap();
let mut app = fixture();
let ready = seed_navigation(&mut app, pending);
let job = ready.as_ref().map(|ready| ready.request.job);
app.publish_source_diagnostics(vec![warning(&app)]);
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let old = warning_messages(&app);
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
let old_source = app.source_key();
let accepted = app.document.snapshot();
let open_menu = app.text_menu_message(Point::new(20., 20.));
send(&mut app, open_menu);
assert!(app.text_workspace.menu().is_some());
let loaded = read_snapshot(path.clone()).unwrap();
send(&mut app, Message::Loaded(Ok(Some(loaded))));
assert!(!app.source_workspace.applied);
assert_eq!(app.path.as_deref(), Some(path.as_path()));
assert_ne!(app.source_key().workspace, old_source.workspace);
assert_eq!(app.source_workspace.text(), invalid);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_cleared(&app, job);
assert!(
app.source_diagnostics
.iter()
.any(|issue| issue.diagnostic.severity == Severity::Error)
);
let publication = app.text_workspace.diagnostic_publication().unwrap();
assert_eq!(publication.source(), app.source_key());
assert_ne!(publication.generation(), old_publication.generation());
for message in old {
send(&mut app, message);
}
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let issue = app
.source_diagnostics
.iter()
.find(|issue| {
issue.path == app.source_workspace.active
&& issue.diagnostic.span.end <= invalid.len()
})
.unwrap();
let current = app
.text_diagnostic_message(SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
})
.unwrap();
send(&mut app, current);
assert!(app.source_symbols.reveal.is_some());
reveal(&mut app);
if let Some(ready) = ready {
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.text(), invalid);
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
for failed in [false, true] {
let mut app = fixture();
seed_navigation(&mut app, false);
app.publish_source_diagnostics(vec![warning(&app)]);
let results = app.source_symbols.results.clone().unwrap();
let cache = app.source_symbols.cache.clone().unwrap();
let display = app.source_symbols.display_request.clone();
let back = app
.source_symbols
.back
.iter()
.map(|entry| {
(
entry.session,
entry.path.clone(),
entry.generation,
entry.cursor,
)
})
.collect::<Vec<_>>();
let key = app.source_key();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let publication = app.text_workspace.diagnostic_publication();
let diagnostics = app.source_diagnostics.clone();
let visit = app.text_workspace.drawer_visit();
let result = if failed {
Err("Fixture read failed before installing a workspace".into())
} else {
Ok(None)
};
send(&mut app, Message::Loaded(result));
assert_eq!(app.source_key(), key);
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_diagnostics, diagnostics);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(Arc::ptr_eq(
app.source_symbols.cache.as_ref().unwrap(),
&cache
));
assert_eq!(app.source_symbols.display_request, display);
assert_eq!(
app.source_symbols
.back
.iter()
.map(|entry| (
entry.session,
entry.path.clone(),
entry.generation,
entry.cursor
))
.collect::<Vec<_>>(),
back
);
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
}
}
#[test]
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let mut app = fixture();
save(&mut app, &first_path);
app.publish_source_diagnostics(vec![warning(&app)]);
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let old = warning_messages(&app);
let publication = app.text_workspace.diagnostic_publication().unwrap();
let draft = format!("{VALID}// unsaved current draft\r\n");
app.source_workspace
.edit(draft.clone(), app.source_editor.cursor());
app.show_source_file(false);
assert_ne!(publication.source(), app.source_key());
let accepted = app.document.snapshot();
save(&mut app, &second_path);
assert!(
app.text_workspace.diagnostic_publication().is_none(),
"identity-only reset cannot turn an old source span into a current warning"
);
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
assert!(
app.text_diagnostic_message(SourceLocation {
path: app.source_diagnostics[0].path.clone(),
span: app.source_diagnostics[0].diagnostic.span,
})
.is_none()
);
for message in old {
send(&mut app, message);
}
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.text(), draft);
assert!(app.source_dirty());
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_eq!(
std::fs::read_to_string(&second_path).unwrap(),
VALID,
"receipt records accepted bytes while the newer draft remains unsaved"
);
}
@@ -1,692 +0,0 @@
use super::*;
use crate::app::Page;
use oa_core::{Project, language::Span};
use std::sync::Arc;
fn fixture() -> App {
let mut app = App::with_project(Project::empty());
app.page = Page::Text;
let diagnostics = app.source_diagnostics.clone();
app.publish_source_diagnostics(diagnostics);
assert!(app.source_navigation_available());
app
}
fn send(app: &mut App, event: Event) {
// Exercise the real envelope path; worker/lint futures are deliberately not
// polled here. The parent owns serialized compile/runtime verification.
let _ = app.update(Message::TextWorkspace(event));
}
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
Event::ToggleFile {
stamp,
visit: app.text_workspace.drawer_visit(),
path,
}
}
fn open(app: &mut App) -> Id {
let message = app.text_menu_message(Point::new(10., 20.));
let _ = app.update(message);
app.text_workspace.menu().unwrap().token()
}
fn invoke(app: &mut App, token: Id, command: Command) {
send(app, Event::Invoke { token, command });
}
fn enabled(app: &App, command: Command) -> bool {
app.text_workspace
.menu
.as_ref()
.unwrap()
.entries
.iter()
.find(|entry| entry.command == command)
.is_some_and(|entry| entry.action.is_some())
}
fn back_entry(app: &App) -> nav::HistoryEntry {
nav::HistoryEntry {
session: app.source_workspace.session(),
path: app.source_workspace.active.clone(),
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
cursor: app.source_editor.cursor(),
}
}
fn stamp(app: &App, serial: u64) -> QueryStamp {
QueryStamp {
snapshot: app.source_key(),
serial,
}
}
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
SourceLocation {
path: app.source_workspace.active.clone(),
span: Span { start, end },
}
}
#[test]
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
let mut app = fixture();
let old_interaction = app.source_symbols.interaction;
let token = open(&mut app);
assert_ne!(old_interaction, app.source_symbols.interaction);
assert_eq!(
app.text_workspace.menu.as_ref().unwrap().context,
app.text_command_context()
);
assert!(enabled(&app, Command::Find));
invoke(&mut app, token, Command::Find);
assert!(app.source_navigation.open);
assert!(app.text_workspace.menu().is_none());
let instance = app.source_find_instance;
invoke(&mut app, token, Command::Find);
assert_eq!(
app.source_find_instance, instance,
"replay must not reopen Find"
);
}
#[test]
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
let mut app = fixture();
let token = open(&mut app);
assert!(!enabled(&app, Command::Back));
app.source_symbols.back.push(back_entry(&app));
assert!(
!enabled(&app, Command::Back),
"frozen presentation must remain disabled"
);
invoke(&mut app, token, Command::Back);
assert_eq!(
app.source_symbols.back.len(),
1,
"a newly enabled action cannot be substituted"
);
assert!(app.source_symbols.reveal.is_none());
}
#[test]
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
let mut app = fixture();
let token = open(&mut app);
assert!(enabled(&app, Command::Find));
app.busy = true;
assert!(enabled(&app, Command::Find));
invoke(&mut app, token, Command::Find);
assert!(!app.source_navigation.open);
assert!(app.text_workspace.menu().is_none());
}
#[test]
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
let mut app = fixture();
let old = open(&mut app);
send(&mut app, Event::DismissMenu(old));
let new = open(&mut app);
send(&mut app, Event::DismissMenu(old));
invoke(&mut app, old, Command::Find);
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
assert!(!app.source_navigation.open);
}
#[test]
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
for change in 0..6 {
let mut app = fixture();
let token = open(&mut app);
match change {
0 => {
let value = format!("{}\n// changed", app.source_workspace.text());
app.source_workspace.edit(value, app.source_editor.cursor());
}
1 => {
let mut cursor = app.source_editor.cursor();
cursor.position.column += 1;
app.source_editor.move_to(cursor);
}
2 => app.canvas_document = Id::new_v4(),
3 => app.source_workspace.active = "other.arcadia".into(),
4 => app.source_symbols.interaction = Id::new_v4(),
_ => {
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("\n// accepted revision");
assert!(app.document.apply_bundle(bundle).unwrap());
}
}
invoke(&mut app, token, Command::Find);
assert!(!app.source_navigation.open, "stale context case {change}");
}
}
#[test]
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
let mut app = fixture();
let accepted = app.document.bundle().clone();
let revision = app.document.revision();
let pending_name = "Pending property draft";
// Use the actual property input route before source edits coexist. Changing
// Draft::value alone is overwritten by its authoritative raw input buffer.
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
let assert_pending_property = |app: &App| {
let draft = app.draft.as_ref().unwrap();
assert!(draft.dirty());
assert_eq!(draft.inputs["/name"].raw, pending_name);
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
assert_eq!(
draft.materialize().unwrap()["name"].as_str(),
Some(pending_name)
);
};
assert_pending_property(&app);
let initial = app.source_workspace.text().to_owned();
app.source_workspace
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
app.show_source_file(false);
assert!(app.source_dirty() && app.draft_dirty());
let token = open(&mut app);
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
invoke(&mut app, token, Command::Find);
assert!(app.source_navigation.open);
assert_pending_property(&app);
let token = open(&mut app);
invoke(&mut app, token, Command::UndoText);
assert_eq!(app.source_workspace.text(), initial);
assert_pending_property(&app);
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn queued_toolbar_request_cannot_retarget_after_source_change() {
let mut app = fixture();
let request = app.text_command_message(Command::Find);
app.source_workspace
.edit("// changed".into(), app.source_editor.cursor());
let _ = app.update(request);
assert!(!app.source_navigation.open);
}
#[test]
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
let mut app = fixture();
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
let previous = app.text_workspace.diagnostic_publication().unwrap();
app.publish_source_diagnostics(app.source_diagnostics.clone());
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_eq!(previous.source(), current.source());
assert_ne!(previous.generation(), current.generation());
for message in [primary, related] {
let interaction = app.source_symbols.interaction;
let _ = app.update(message);
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.reveal.is_none());
}
}
#[test]
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
let mut app = fixture();
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_some());
app.source_interaction();
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
app.source_workspace
.edit("// newer".into(), app.source_editor.cursor());
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_none());
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
app.publish_source_diagnostics(Vec::new());
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
app.publish_source_diagnostics(Vec::new());
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_none());
}
#[test]
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
let mut app = fixture();
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
app.source_workspace
.edit("a雨b".into(), app.source_editor.cursor());
app.show_source_file(false);
app.publish_source_diagnostics(Vec::new());
let mut missing = location(&app, 0, 1);
missing.path = "missing.arcadia".into();
for target in [
location(&app, 3, 2),
location(&app, 0, 6),
location(&app, 2, 4),
location(&app, 1, 3),
missing,
] {
let message = app.text_diagnostic_message(target).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_none());
}
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_some());
}
#[test]
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
let mut app = fixture();
let first = stamp(&app, 1);
app.text_workspace
.begin_references(first, "b/use.arcadia".into());
app.text_workspace
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
let event = toggle(&app, first, "a/use.arcadia".into());
send(&mut app, event);
let old_visit = app.text_workspace.drawer_visit();
let old_scroll_id = app.text_workspace.reference_scroll_id();
let interaction = app.source_symbols.interaction;
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 123.,
},
);
assert_eq!(
app.source_symbols.interaction, interaction,
"scroll is passive"
);
send(&mut app, Event::ShowDrawer(DrawerTab::Status));
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 0.,
},
);
send(&mut app, Event::ShowDrawer(DrawerTab::References));
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 0.,
},
);
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
let second = stamp(&app, 2);
app.text_workspace
.begin_references(second, "absent.arcadia".into());
app.text_workspace
.install_reference_files(first, ["old.arcadia".into()]);
app.text_workspace
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
let event = toggle(&app, first, "b/use.arcadia".into());
send(&mut app, event);
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
}
#[test]
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
let mut app = fixture();
open(&mut app);
app.text_workspace.reset();
assert!(app.text_workspace.menu().is_none());
assert!(app.text_workspace.diagnostic_publication().is_none());
assert!(app.text_workspace.drawer().is_none());
send(&mut app, Event::ResizeDrawer(f32::NAN));
assert_eq!(app.text_workspace.drawer_height(), 180.);
send(&mut app, Event::ResizeDrawer(f32::MAX));
assert_eq!(app.text_workspace.drawer_height(), 1200.);
}
#[test]
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
let mut app = fixture();
let current = stamp(&app, 1);
app.text_workspace
.begin_references(current, "uses.arcadia".into());
app.text_workspace
.install_reference_files(current, ["uses.arcadia".into()]);
let request = nav::Request {
job: Id::new_v4(),
interaction: app.source_symbols.interaction,
raw_interaction: 0,
stamp: current,
path: app.source_workspace.active.clone(),
offset: 0,
cursor: app.source_editor.cursor(),
action: nav::query::Action::References,
};
app.source_symbols.request = Some(request.clone());
app.source_symbols.in_flight = Some(request.job);
let interaction = app.source_symbols.interaction;
let event = toggle(&app, current, "not-a-group.arcadia".into());
send(&mut app, event);
let old = QueryStamp {
serial: 0,
..current
};
let event = toggle(&app, old, "uses.arcadia".into());
send(&mut app, event);
send(&mut app, Event::ResizeDrawer(f32::NAN));
send(&mut app, Event::ResizeDrawer(180.));
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.request.is_some());
let event = toggle(&app, current, "uses.arcadia".into());
send(&mut app, event);
assert!(app.source_symbols.request.is_none());
assert_ne!(app.source_symbols.interaction, interaction);
assert_eq!(
app.source_symbols.in_flight,
Some(request.job),
"intent does not release an occupied worker"
);
app.source_symbols.request = Some(request.clone());
send(&mut app, Event::ResizeDrawer(240.));
assert!(app.source_symbols.request.is_none());
app.source_symbols.request = Some(request.clone());
send(&mut app, Event::HideDrawer);
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.text_workspace.references().stamp(), Some(current));
}
#[test]
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
let mut app = fixture();
let current = stamp(&app, 1);
let path = app.source_workspace.active.clone();
app.text_workspace.begin_references(current, path.clone());
app.text_workspace
.install_reference_files(current, [path.clone()]);
let event = toggle(&app, current, path.clone());
send(&mut app, event);
assert!(app.text_workspace.references().collapsed(&path));
let accepted = app.document.snapshot();
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
widget::text_editor::Edit::Insert(' '),
)));
assert_ne!(current.snapshot, app.source_key());
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let event = toggle(&app, current, path.clone());
send(&mut app, event);
assert!(!app.text_workspace.references().collapsed(&path));
assert_eq!(app.source_workspace.active, path);
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
let mut app = fixture();
let old = open(&mut app);
let sequence = app.text_workspace_menu().unwrap().token;
app.reset(Project::empty(), None);
assert!(app.text_workspace.menu().is_none());
app.page = Page::Text;
let fresh = open(&mut app);
assert_ne!(old, fresh);
assert!(app.text_workspace_menu().unwrap().token > sequence);
invoke(&mut app, old, Command::Find);
assert!(!app.source_navigation.open);
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
}
#[test]
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
for hide in [false, true] {
let mut app = fixture();
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
let old_visit = app.text_workspace.drawer_visit();
let publication = app.text_workspace.diagnostic_publication();
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
let old = targets
.iter()
.cloned()
.map(|target| app.text_diagnostic_message(target).unwrap())
.collect::<Vec<_>>();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let history = (
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
let event = if hide {
Event::HideDrawer
} else {
Event::ShowDrawer(DrawerTab::References)
};
send(&mut app, event);
for reopened in [false, true] {
if reopened {
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
} else {
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
}
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert_eq!(
app.text_workspace.diagnostic_publication(),
publication,
"same list and source isolate the visit guard from publication freshness"
);
let context = app.text_command_context();
let drawer = app.text_workspace.drawer();
let visit = app.text_workspace.drawer_visit();
for message in old.iter().cloned() {
let _ = app.update(message);
assert_eq!(
app.text_command_context(),
context,
"stale diagnostic must fail before interaction invalidation"
);
assert_eq!(app.text_workspace.drawer(), drawer);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(
(
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
}
}
for target in targets {
let fresh = app.text_diagnostic_message(target).unwrap();
let _ = app.update(fresh);
assert!(
app.source_symbols.reveal.is_some(),
"fresh primary and related targets remain usable"
);
app.source_interaction();
}
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
for replacement in 0..3 {
let mut app = fixture();
let current = stamp(&app, 1);
app.text_workspace
.begin_references(current, "a.arcadia".into());
app.text_workspace
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
let old = toggle(&app, current, "a.arcadia".into());
let old_visit = app.text_workspace.drawer_visit();
match replacement {
0 => {
send(&mut app, Event::HideDrawer);
send(&mut app, Event::ShowDrawer(DrawerTab::References));
}
1 => {
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
send(&mut app, Event::ShowDrawer(DrawerTab::References));
}
_ => {
let event = toggle(&app, current, "b.arcadia".into());
send(&mut app, event);
}
}
let visit = app.text_workspace.drawer_visit();
assert_ne!(visit, old_visit);
send(
&mut app,
Event::ReferenceScrolled {
stamp: current,
visit,
y: 84.,
},
);
let scroll_id = app.text_workspace.reference_scroll_id();
let request = nav::Request {
job: Id::new_v4(),
interaction: app.source_symbols.interaction,
raw_interaction: 0,
stamp: current,
path: app.source_workspace.active.clone(),
offset: 0,
cursor: app.source_editor.cursor(),
action: nav::query::Action::References,
};
app.source_symbols.request = Some(request.clone());
app.source_symbols.in_flight = Some(request.job);
let groups =
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
let context = app.text_command_context();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
for _ in 0..2 {
send(&mut app, old.clone());
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
assert_eq!(app.text_workspace.references().stamp(), Some(current));
assert_eq!(
["a.arcadia", "b.arcadia"]
.map(|path| app.text_workspace.references().collapsed(path)),
groups,
);
assert_eq!(app.text_command_context(), context);
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
}
let fresh = toggle(&app, current, "a.arcadia".into());
send(&mut app, fresh);
assert_eq!(
app.text_workspace.references().collapsed("a.arcadia"),
!groups[0]
);
assert_ne!(app.text_workspace.drawer_visit(), visit);
assert_ne!(app.source_symbols.interaction, context.interaction);
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn annotation_snapshot_requires_exact_current_bytes_and_publication() {
let mut app = fixture();
let original_model = app.document.snapshot();
let original_source = app.source_snapshot.clone();
let publication = app.source_annotation_snapshot().unwrap().publication;
let initial = app.source_annotation_snapshot().unwrap();
assert!(Arc::ptr_eq(&initial.source, &original_source));
assert_eq!(initial.path, app.source_workspace.active);
assert!(std::ptr::eq(
initial.diagnostics,
app.source_diagnostics.as_slice()
));
// Republish identical diagnostics: old cached geometry must get a new identity.
app.publish_source_diagnostics(app.source_diagnostics.clone());
assert_ne!(
app.source_annotation_snapshot().unwrap().publication,
publication
);
app.source_workspace
.edit("// newer draft\n".into(), app.source_editor.cursor());
assert!(
app.source_annotation_snapshot().is_none(),
"old publication after a real draft edit"
);
// A current source key alone must not bind new ranges to the old displayed text.
app.publish_source_diagnostics(Vec::new());
assert!(
app.source_annotation_snapshot().is_none(),
"displayed snapshot still contains old bytes"
);
app.show_source_file(false);
let current = app.source_annotation_snapshot().unwrap();
assert_eq!(current.source.as_ref(), "// newer draft\n");
assert!(current.diagnostics.is_empty());
assert!(Arc::ptr_eq(&original_model, &app.document.snapshot()));
app.text_workspace.reset();
assert!(app.source_annotation_snapshot().is_none());
}
#[test]
fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() {
let mut project = Project::empty();
let element = project
.add_element(
oa_core::Kind::Activity,
oa_core::Layer::Oa,
"Assess incident",
None,
)
.unwrap();
let mut app = App::with_project(project);
app.page = Page::Text;
app.publish_source_diagnostics(app.source_diagnostics.clone());
let model = app.document.snapshot();
let entry = app.source_workspace.entry.clone();
let fragment = app.document.compiled().index.declarations[&element]
.path
.clone();
assert_ne!(entry, fragment);
for path in [&entry, &fragment, &entry] {
let _ = app.update(Message::SourceFile(path.clone()));
let current = app.source_annotation_snapshot().unwrap();
assert_eq!(current.path, path);
assert_eq!(
current.source.as_ref(),
app.source_workspace.files[path].text
);
assert!(Arc::ptr_eq(&current.source, &app.source_snapshot));
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-301
View File
@@ -1,301 +0,0 @@
//! Publication-and-visit-bound input for a mounted diagnostic list.
//!
//! Keep this wrapper at the same widget-tree position, inside the list's outer
//! scroller. Pass `None` with an empty child while hidden/removed; unmounting the
//! wrapper loses its ability to consume an outstanding release. Every change to
//! row identity/order/action must have a fresh publication Id. Pass the pair
//! (publication generation, drawer visit): a new visit resets armed controls even
//! if Hide and Show coalesce without an intermediate hidden Tree. The child tree
//! resets when either component changes, clearing button presses and row focus;
//! the outer scroller and editor trees are not reset.
//!
//! Capturing stops Iced event bubbling and later consumers that respect capture.
//! It cannot undo an earlier sibling's event handling. This is a row-list guard,
//! not a root event filter or a guard for actions in child overlays. Diagnostic
//! messages still need publication/snapshot and drawer-visit validation at dispatch.
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
use iced::{Element, Event, Length, Rectangle, Size, Vector, mouse, touch};
use oa_core::Id;
pub struct Guard<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
identity: Option<(Id, Id)>,
content: Element<'a, Message, Theme, Renderer>,
}
impl<'a, Message, Theme, Renderer> Guard<'a, Message, Theme, Renderer> {
pub fn new(
identity: Option<(Id, Id)>,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
identity,
content: content.into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pointer {
Mouse,
Touch(touch::Finger),
}
impl Pointer {
fn press(event: &Event) -> Option<Self> {
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => Some(Self::Mouse),
Event::Touch(touch::Event::FingerPressed { id, .. }) => Some(Self::Touch(*id)),
_ => None,
}
}
fn release(event: &Event) -> Option<Self> {
match event {
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => Some(Self::Mouse),
Event::Touch(
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
) => Some(Self::Touch(*id)),
_ => None,
}
}
}
struct State {
identity: Option<(Id, Id)>,
pressed: Option<Pointer>,
swallowed: Vec<Pointer>,
}
impl State {
fn quarantine(&mut self, pointer: Pointer) {
if !self.swallowed.contains(&pointer) {
self.swallowed.push(pointer);
}
}
fn forget(&mut self, pointer: Pointer) -> bool {
let found = self.swallowed.contains(&pointer);
self.swallowed.retain(|item| *item != pointer);
found
}
}
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
for Guard<'_, Message, Theme, Renderer>
{
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State {
identity: self.identity,
pressed: None,
swallowed: Vec::new(),
})
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&self, tree: &mut widget::Tree) {
let state = tree.state.downcast_mut::<State>();
if state.identity != self.identity {
state.identity = self.identity;
if let Some(pointer) = state.pressed.take() {
state.quarantine(pointer);
}
tree.children = self.children();
} else {
tree.diff_children(std::slice::from_ref(&self.content));
}
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
let release = Pointer::release(event);
if let Some(pointer) = release {
// Bounds may have become empty or moved. Consume the physical
// gesture's terminal event regardless of its current position.
if state.forget(pointer) {
shell.capture_event();
return;
}
// Iced Button has a single is_pressed bit, not a pointer identity.
// A different finger/mouse release must not activate that button.
if state.pressed.is_some_and(|pressed| pressed != pointer) {
shell.capture_event();
return;
}
}
let press = Pointer::press(event);
if let Some(pointer) = state.pressed.filter(|pointer| {
press == Some(*pointer) || (release == Some(*pointer) && shell.is_event_captured())
}) {
// Recover an omitted terminal event, or cancel one intercepted by
// an earlier consumer. An off-target mouse release / lost touch
// clears Iced Button's pressed state without rebuilding this
// publication's tree (which would also discard row focus).
state.pressed = None;
let cancel = match pointer {
Pointer::Mouse => Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Pointer::Touch(id) => Event::Touch(touch::Event::FingerLost {
id,
position: iced::Point::ORIGIN,
}),
};
let mut discarded = Vec::new();
let mut cancellation = Shell::new(&mut discarded);
self.content.as_widget_mut().update(
&mut tree.children[0],
&cancel,
layout,
mouse::Cursor::Unavailable,
renderer,
clipboard,
&mut cancellation,
viewport,
);
// Cancellation must not publish an action or capture the fresh
// down. Preserve rendering requests from the state change only.
shell.request_redraw();
if cancellation.is_layout_invalid() {
shell.invalidate_layout();
}
if cancellation.are_widgets_invalid() {
shell.invalidate_widgets();
}
}
if shell.is_event_captured() || self.identity.is_none() {
return;
}
if let Some(pointer) = press {
// A fresh down starts a new gesture even if the platform omitted a
// previous release (for example while the window lacked focus).
state.forget(pointer);
if let Some(pressed) = state.pressed {
if pressed != pointer {
state.quarantine(pointer);
}
shell.capture_event();
return;
}
}
let was_pressed = release.is_some() && release == state.pressed;
if was_pressed {
state.pressed = None;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if let Some(pointer) = press
&& shell.is_event_captured()
{
state.pressed = Some(pointer);
}
if was_pressed {
// FingerLost cancels Iced Button state but does not itself capture.
shell.capture_event();
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>
From<Guard<'a, Message, Theme, Renderer>> for Element<'a, Message, Theme, Renderer>
{
fn from(guard: Guard<'a, Message, Theme, Renderer>) -> Self {
Element::new(guard)
}
}
#[cfg(test)]
mod tests;
@@ -1,626 +0,0 @@
use super::*;
use iced::advanced::widget::operation::{self, Focusable, Scrollable};
use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input};
use iced::{Point, Theme, event, keyboard};
type View = Element<'static, Message, Theme, ()>;
const BOUNDS: Rectangle = Rectangle {
x: 0.,
y: 0.,
width: 400.,
height: 300.,
};
#[derive(Clone, Debug, PartialEq, Eq)]
enum Message {
Primary(u8),
Related(u8),
BackgroundButton,
BackgroundRelease,
Submit(u8),
Edit,
}
fn rows(version: u8) -> View {
column![
button(Space::new())
.width(200.)
.height(40.)
.padding(0)
.on_press(Message::Primary(version)),
button(Space::new())
.width(200.)
.height(40.)
.padding(0)
.on_press(Message::Related(version)),
]
.into()
}
fn guarded(generation: Option<Id>, version: u8) -> View {
let child: View = if generation.is_some() {
rows(version)
} else {
Space::new().into()
};
Guard::new(
generation.map(|publication| (publication, Id::nil())),
child,
)
.into()
}
fn press() -> Event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
}
fn release() -> Event {
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
}
fn enter() -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Enter),
modified_key: keyboard::Key::Named(keyboard::key::Named::Enter),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Enter),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn touch_down(id: u64, position: Point) -> Event {
Event::Touch(touch::Event::FingerPressed {
id: touch::Finger(id),
position,
})
}
fn touch_up(id: u64, position: Point) -> Event {
Event::Touch(touch::Event::FingerLifted {
id: touch::Finger(id),
position,
})
}
fn touch_lost(id: u64, position: Point) -> Event {
Event::Touch(touch::Event::FingerLost {
id: touch::Finger(id),
position,
})
}
struct Mounted {
view: View,
tree: widget::Tree,
node: layout::Node,
}
impl Mounted {
fn new(mut view: View) -> Self {
let mut tree = widget::Tree::new(view.as_widget());
let node = view.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
);
Self { view, tree, node }
}
fn replace(&mut self, mut view: View) {
self.tree.diff(view.as_widget());
self.node = view.as_widget_mut().layout(
&mut self.tree,
&(),
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
);
self.view = view;
}
fn event(&mut self, event: Event, point: Point) -> (Vec<Message>, event::Status) {
self.event_with_capture(event, point, false)
}
fn event_with_capture(
&mut self,
event: Event,
point: Point,
captured: bool,
) -> (Vec<Message>, event::Status) {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
if captured {
shell.capture_event();
}
self.view.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Available(point),
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&BOUNDS,
);
let status = shell.event_status();
(messages, status)
}
fn operate(&mut self, operation: &mut dyn widget::Operation) {
self.view
.as_widget_mut()
.operate(&mut self.tree, Layout::new(&self.node), &(), operation);
}
fn focus(&mut self) {
self.operate(&mut operation::focusable::focus::<()>("row".into()));
}
fn focused(&mut self) -> bool {
#[derive(Default)]
struct Focus(bool);
impl widget::Operation for Focus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn Focusable,
) {
self.0 |= state.is_focused();
}
}
let mut focus = Focus::default();
self.operate(&mut focus);
focus.0
}
fn scroll(&mut self) -> Vector {
#[derive(Default)]
struct Offset(Option<Vector>);
impl widget::Operation for Offset {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn Scrollable,
) {
self.0 = Some(translation);
}
}
let mut offset = Offset::default();
self.operate(&mut offset);
offset.0.unwrap()
}
}
#[test]
fn current_primary_and_related_mouse_clicks_survive_same_publication_diff() {
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
let generation = Id::new_v4();
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(generation), 1));
assert_eq!(ui.event(press(), point), (vec![], event::Status::Captured));
ui.replace(guarded(Some(generation), 1));
assert_eq!(
ui.event(release(), point),
(vec![expected], event::Status::Captured)
);
}
}
#[test]
fn same_publication_new_visit_cancels_armed_rows_without_a_hidden_tree_frame() {
for touch in [false, true] {
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
let publication = Id::new_v4();
let old_visit = Id::new_v4();
let new_visit = Id::new_v4();
let point = Point::new(20., y);
let mut ui = Mounted::new(Guard::new(Some((publication, old_visit)), rows(1)).into());
let down = || if touch { touch_down(7, point) } else { press() };
let up = || if touch { touch_up(7, point) } else { release() };
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
// Hide→Show has already happened in application state. No None
// publication or empty child is ever diffed into this retained Tree.
ui.replace(Guard::new(Some((publication, new_visit)), rows(2)).into());
assert_eq!(ui.event(up(), point), (vec![], event::Status::Captured));
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
assert_eq!(
ui.event(up(), point),
(vec![expected], event::Status::Captured)
);
}
}
}
#[test]
fn negative_control_iced_button_retargets_a_press_after_plain_tree_diff() {
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
let point = Point::new(20., y);
let mut ui = Mounted::new(rows(1));
assert!(ui.event(press(), point).0.is_empty());
ui.replace(rows(2));
assert_eq!(ui.event(release(), point).0, vec![expected]);
}
}
#[test]
fn replaced_primary_and_related_rows_consume_stale_release_then_accept_new_click() {
for y in [20., 60.] {
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(press(), point);
ui.replace(guarded(Some(Id::new_v4()), 2));
ui.replace(guarded(Some(Id::new_v4()), 3));
assert_eq!(
ui.event(release(), point),
(vec![], event::Status::Captured)
);
ui.event(press(), point);
let expected = if y < 40. {
Message::Primary(3)
} else {
Message::Related(3)
};
assert_eq!(
ui.event(release(), point),
(vec![expected], event::Status::Captured)
);
}
}
#[test]
fn hidden_zero_size_list_consumes_release_outside_old_bounds_once() {
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(press(), Point::new(20., 20.));
ui.replace(guarded(None, 0));
ui.replace(guarded(None, 0));
assert_eq!(ui.node.size(), Size::ZERO);
let outside = Point::new(350., 250.);
assert_eq!(
ui.event(release(), outside),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(release(), outside),
(vec![], event::Status::Ignored)
);
ui.replace(guarded(Some(Id::new_v4()), 2));
assert_eq!(ui.event(release(), Point::new(20., 20.)).0, vec![]);
}
fn with_background(content: View) -> View {
mouse_area(
button(container(content).width(250.).height(100.))
.padding(0)
.on_press(Message::BackgroundButton),
)
.on_release(Message::BackgroundRelease)
.into()
}
#[test]
fn stale_release_does_not_bubble_to_real_background_controls() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(with_background(guarded(Some(Id::new_v4()), 1)));
ui.event(press(), point);
ui.replace(with_background(guarded(None, 0)));
assert_eq!(
ui.event(release(), point),
(vec![], event::Status::Captured)
);
// The enclosing real button still supports a fresh ordinary click.
ui.event(press(), point);
assert_eq!(
ui.event(release(), point).0,
vec![Message::BackgroundButton]
);
}
#[test]
fn negative_control_removing_unguarded_rows_exposes_background_release() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(with_background(rows(1)));
ui.event(press(), point);
ui.replace(with_background(Space::new().into()));
assert_eq!(
ui.event(release(), point).0,
vec![Message::BackgroundRelease]
);
}
#[test]
fn current_touch_activates_primary_and_related_and_cancel_does_not_activate() {
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
assert_eq!(
ui.event(touch_down(1, point), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(touch_up(1, point), point),
(vec![expected], event::Status::Captured)
);
ui.event(touch_down(2, point), point);
assert_eq!(
ui.event(touch_lost(2, point), point),
(vec![], event::Status::Captured)
);
assert!(ui.event(touch_up(2, point), point).0.is_empty());
}
}
#[test]
fn replaced_touch_consumes_lift_or_cancel_even_when_hidden() {
for hide in [false, true] {
for lost in [false, true] {
for y in [20., 60.] {
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(touch_down(1, point), point);
ui.replace(guarded(if hide { None } else { Some(Id::new_v4()) }, 2));
let end = if lost {
touch_lost(1, point)
} else {
touch_up(1, point)
};
assert_eq!(ui.event(end, point), (vec![], event::Status::Captured));
}
}
}
}
#[test]
fn unrelated_finger_or_mouse_release_cannot_complete_another_fingers_press() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(touch_down(1, point), point);
ui.event(touch_down(2, point), point);
assert_eq!(
ui.event(touch_up(2, point), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(release(), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(touch_up(1, point), point).0,
vec![Message::Primary(1)]
);
}
fn keyboard_row(version: u8) -> View {
// Iced 0.14.2 Button has no focus/keyboard activation. A real focusable
// submit control tests the wrapper's focus lifecycle without inventing it.
text_input("Diagnostic action", "row")
.id("row")
.on_input(|_| Message::Edit)
.on_submit(Message::Submit(version))
.into()
}
#[test]
fn same_publication_preserves_focus_but_replacement_never_inherits_activation() {
let generation = Id::new_v4();
let mut ui = Mounted::new(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
ui.focus();
assert!(ui.focused());
ui.replace(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
assert!(ui.focused());
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(2)).into());
assert!(!ui.focused());
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
ui.focus();
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
ui.replace(Guard::new(None, Space::new()).into());
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(3)).into());
assert!(!ui.focused());
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
}
#[test]
fn negative_control_plain_focusable_row_migrates_activation_to_replacement() {
let mut ui = Mounted::new(keyboard_row(1));
ui.focus();
ui.replace(keyboard_row(2));
assert!(ui.focused());
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
}
#[test]
fn outer_scroll_offset_survives_same_and_changed_publications() {
fn pane(generation: Id) -> View {
scrollable(Guard::new(
Some((generation, Id::nil())),
column![rows(1), Space::new().height(600.)],
))
.width(240.)
.height(100.)
.into()
}
let generation = Id::new_v4();
let mut ui = Mounted::new(pane(generation));
ui.event(
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -120. },
}),
Point::new(20., 20.),
);
let offset = ui.scroll();
assert_eq!(offset, Vector::new(0., 120.));
ui.replace(pane(generation));
assert_eq!(ui.scroll(), offset);
ui.replace(pane(Id::new_v4()));
assert_eq!(ui.scroll(), offset);
}
#[test]
fn stale_touch_release_does_not_disarm_a_new_current_gesture() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(touch_down(1, point), point);
ui.replace(guarded(Some(Id::new_v4()), 2));
ui.event(touch_down(2, point), point);
assert_eq!(
ui.event(touch_up(1, point), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(touch_up(2, point), point).0,
vec![Message::Primary(2)]
);
}
#[test]
fn fresh_mouse_press_after_missing_stale_release_can_activate_current_row() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(press(), point);
ui.replace(guarded(Some(Id::new_v4()), 2));
// The platform delivers another down without the previous up.
ui.event(press(), point);
assert_eq!(ui.event(release(), point).0, vec![Message::Primary(2)]);
}
#[test]
fn missed_press_is_ignored_and_dragging_release_outside_does_not_activate() {
let inside = Point::new(20., 20.);
let outside = Point::new(350., 250.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
assert_eq!(ui.event(press(), outside), (vec![], event::Status::Ignored));
assert_eq!(
ui.event(release(), inside),
(vec![], event::Status::Ignored)
);
ui.event(press(), inside);
assert_eq!(
ui.event(release(), outside),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(release(), inside),
(vec![], event::Status::Ignored)
);
}
#[test]
fn same_publication_missing_terminal_recovers_on_fresh_mouse_or_touch_down() {
let primary = Point::new(20., 20.);
let related = Point::new(20., 60.);
for is_touch in [false, true] {
let generation = Id::new_v4();
let mut ui = Mounted::new(guarded(Some(generation), 1));
let down = |point| {
if is_touch {
touch_down(1, point)
} else {
press()
}
};
let up = |point| {
if is_touch {
touch_up(1, point)
} else {
release()
}
};
ui.event(down(primary), primary);
ui.replace(guarded(Some(generation), 1));
// The first gesture never receives its terminal event; same Id means
// no publication reset can accidentally make this regression pass.
assert_eq!(
ui.event(down(related), related),
(vec![], event::Status::Captured)
);
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
assert!(ui.event(up(primary), primary).0.is_empty());
}
}
#[test]
fn already_captured_terminal_cancels_child_without_activation_or_lingering_press() {
let primary = Point::new(20., 20.);
let related = Point::new(20., 60.);
for is_touch in [false, true] {
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
let down = |point| {
if is_touch {
touch_down(1, point)
} else {
press()
}
};
let up = |point| {
if is_touch {
touch_up(1, point)
} else {
release()
}
};
ui.event(down(primary), primary);
assert_eq!(
ui.event_with_capture(up(primary), primary, true),
(vec![], event::Status::Captured)
);
// An unpaired second release must not expose a lingering button press.
assert_eq!(
ui.event(up(primary), primary),
(vec![], event::Status::Ignored)
);
ui.event(down(related), related);
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
}
}
#[test]
fn captured_terminal_cancellation_preserves_same_publication_focusable_child_state() {
let generation = Id::new_v4();
let mut ui = Mounted::new(
Guard::new(
Some((generation, Id::nil())),
column![rows(1), keyboard_row(1)],
)
.into(),
);
ui.event(press(), Point::new(20., 20.));
// Focus through an operation avoids making the old pointer gesture whole.
ui.focus();
assert!(ui.focused());
assert!(
ui.event_with_capture(release(), Point::new(20., 20.), true)
.0
.is_empty()
);
assert!(
ui.focused(),
"gesture cancellation must not remount the row tree"
);
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
}
#[test]
fn hidden_zero_height_scroller_delivers_cancelled_mouse_and_touch_terminals_to_guard() {
fn pane(generation: Option<Id>) -> View {
scrollable(guarded(generation, 1))
.width(240.)
.height(if generation.is_some() { 100. } else { 0. })
.into()
}
let point = Point::new(20., 20.);
let outside = Point::new(350., 250.);
for is_touch in [false, true] {
let mut ui = Mounted::new(pane(Some(Id::new_v4())));
let down = if is_touch {
touch_down(1, point)
} else {
press()
};
assert_eq!(ui.event(down, point), (vec![], event::Status::Captured));
ui.replace(pane(None));
ui.replace(pane(None));
assert_eq!(ui.node.size().height, 0.);
let up = if is_touch {
touch_up(1, outside)
} else {
release()
};
assert_eq!(ui.event(up, outside), (vec![], event::Status::Captured));
}
}
+1 -17
View File
@@ -10,10 +10,6 @@ use std::{fmt, sync::Arc};
const HISTORY_LIMIT: usize = 100;
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
#[path = "document_alias_rename.rs"]
mod alias_rename;
pub use alias_rename::PreparedAliasRename;
struct HistoryEntry {
snapshot: Arc<CompiledSourceProject>,
bytes: usize,
@@ -95,8 +91,6 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
}
pub struct Document {
// Distinguishes equal accepted snapshots belonging to different documents.
alias_owner: Arc<()>,
current: Arc<CompiledSourceProject>,
past: Vec<HistoryEntry>,
future: Vec<HistoryEntry>,
@@ -127,7 +121,6 @@ impl Document {
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
let current_bytes = estimated_bytes(&current);
Ok(Self {
alias_owner: Arc::new(()),
current,
current_bytes,
past: Vec::new(),
@@ -401,17 +394,8 @@ impl Document {
return false;
}
let bytes = estimated_bytes(&compiled);
self.commit_snapshot(Arc::new(compiled), bytes)
}
// Only validated internal paths supply a changed snapshot and its exact
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
// path checks its private changed flag and exact accepted-snapshot authority.
// Alias preparation shares this algorithm while retaining the live save and
// history state, and does not clone/recompile the candidate on adoption.
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
self.past.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, compiled),
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
bytes: std::mem::replace(&mut self.current_bytes, bytes),
});
self.future.clear();
@@ -1,233 +0,0 @@
//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
//! not authorize adoption; only this module can mint a prepared rename.
use super::*;
use std::collections::BTreeMap;
/// A pinned intent minted by its owning Document. Move it to a worker and call
/// `prepare`; preparation has no access to the live Document or its histories.
#[derive(Debug)]
pub struct AliasRenameRequest {
owner: Arc<()>,
before: Arc<CompiledSourceProject>,
revision: u64,
limits: ProjectLimits,
target: Id,
old_alias: String,
new_alias: String,
}
#[derive(Debug)]
struct PreparedData {
request: AliasRenameRequest,
after: Arc<CompiledSourceProject>,
before_bytes: usize,
after_bytes: usize,
changed: bool,
}
/// Immutable, Clone-compatible transport for preview and approval. Neither
/// mutable candidate access nor deserialization can manufacture this authority.
#[derive(Debug, Clone)]
pub struct PreparedAliasRename(Arc<PreparedData>);
impl PreparedAliasRename {
pub fn before(&self) -> &CompiledSourceProject {
&self.0.request.before
}
pub fn after(&self) -> &CompiledSourceProject {
&self.0.after
}
pub fn target(&self) -> Id {
self.0.request.target
}
pub fn old_alias(&self) -> &str {
&self.0.request.old_alias
}
pub fn new_alias(&self) -> &str {
&self.0.request.new_alias
}
pub fn revision(&self) -> u64 {
self.0.request.revision
}
pub fn changed(&self) -> bool {
self.0.changed
}
}
fn refusal(message: &str) -> DocumentError {
ModelError(message.into()).into()
}
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
return Err(refusal(
"This rename exceeds the model Undo budget. No rename was applied.",
));
}
Ok(())
}
impl Document {
pub fn alias_rename_request(
&self,
target: Id,
replacement: String,
) -> Result<AliasRenameRequest, DocumentError> {
if self.revision == u64::MAX {
return Err(refusal(
"The document revision is exhausted. Reopen before renaming.",
));
}
let old_alias = self
.project()
.identities()
.into_iter()
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
.ok_or_else(|| refusal("The alias target is no longer present."))?;
// A candidate has a nonzero estimate. Avoid expensive preparation when
// even the current snapshot leaves no room for its immediate Undo.
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
return Err(refusal(
"This rename exceeds the model Undo budget. No rename was applied.",
));
}
Ok(AliasRenameRequest {
owner: self.alias_owner.clone(),
before: self.current.clone(),
revision: self.revision,
limits: self.limits,
target,
old_alias,
new_alias: replacement,
})
}
/// App must separately consume its current approval and check workspace,
/// invocation and draft freshness. This checks accepted Document authority.
pub fn commit_alias_rename(
&mut self,
prepared: &PreparedAliasRename,
) -> Result<bool, DocumentError> {
let data = &prepared.0;
let request = &data.request;
if self.revision == u64::MAX
|| self.revision != request.revision
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|| !Arc::ptr_eq(&self.current, &request.before)
{
return Err(refusal(
"This rename preview is stale. Preview the current alias again.",
));
}
if !data.changed {
return Ok(false);
}
if self.current_bytes != data.before_bytes {
return Err(refusal(
"The rename history estimate is stale. Preview again.",
));
}
check_history_capacity(self.current_bytes, data.after_bytes)?;
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
}
}
impl AliasRenameRequest {
/// Run on the preparation worker. The typed command and source updater are
/// the only candidate construction path, followed by alias-only invariants.
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
let before_project = &self.before.decoded.project;
let candidate = prepare_command(
before_project,
&Command::SetAlias(self.target, self.new_alias.clone()),
)?;
let mut expected = before_project.clone();
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
let identities: BTreeMap<_, _> = before_project
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
if candidate.project != expected || candidate.identity_map != identities {
return Err(refusal(
"Alias preparation changed unrelated model data or identities.",
));
}
let after = source_project::update_bundle_with_identity_map(
&self.before,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
let before = &self.before;
if after.decoded.project != expected
|| before.bundle.entry != after.bundle.entry
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|| before.index.lexical_owners != after.index.lexical_owners
|| !before
.index
.declarations
.iter()
.map(|(id, loc)| (id, &loc.path))
.eq(after
.index
.declarations
.iter()
.map(|(id, loc)| (id, &loc.path)))
{
return Err(refusal(
"Alias preparation changed source membership or ownership.",
));
}
let changed = before.bundle != after.bundle;
let before_bytes = estimated_bytes(before);
let after_bytes = estimated_bytes(&after);
if changed {
check_history_capacity(before_bytes, after_bytes)?;
}
Ok(PreparedAliasRename(Arc::new(PreparedData {
request: self,
after: Arc::new(after),
before_bytes,
after_bytes,
changed,
})))
}
}
// Independent full-model expectation: SetAlias may change exactly one header
// field, across every supported identity family. Do not reuse command execution
// to derive the expected result, which would hide collateral command changes.
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
if project.id == id {
project.alias = alias.to_owned();
} else if let Some(record) = project.elements.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.relations.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.diagrams.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.artifact_links.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.traces.get_mut(&id) {
record.alias = alias.to_owned();
} else {
project
.diagrams
.values_mut()
.find_map(|diagram| diagram.nodes.get_mut(&id))
.ok_or_else(|| refusal("The alias target is no longer present."))?
.alias = alias.to_owned();
}
Ok(())
}
#[cfg(test)]
#[path = "document_alias_rename_tests.rs"]
mod tests;
@@ -1,459 +0,0 @@
use super::*;
fn fixture() -> Document {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), r#"arcadia 7;
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
include "work.arcadia"; include "views.arcadia";
}
"#.replace('\n', "\r\n")),
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
assess; }
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
"#.replace('\n', "\r\n")),
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
"#.replace('\n', "\r\n")),
]),
};
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
assert_eq!(
document.bundle(),
&bundle,
"fixture must need no migration or ID insertion"
);
document
}
fn id(number: u8) -> Id {
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
}
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
document
.alias_rename_request(target, replacement.into())
.unwrap()
.prepare()
.unwrap()
}
// Capture every live Document state relevant to refusal/no-op. Arc identity also
// catches replacing an equal snapshot or saved marker behind the user's back.
struct Unchanged {
current: Arc<CompiledSourceProject>,
saved: Option<Arc<SourceBundle>>,
past: Vec<(Arc<CompiledSourceProject>, usize)>,
future: Vec<(Arc<CompiledSourceProject>, usize)>,
revision: u64,
current_bytes: usize,
recovered: bool,
}
impl Unchanged {
fn capture(document: &Document) -> Self {
Self {
current: document.snapshot(),
saved: document.saved.clone(),
past: document
.past
.iter()
.map(|h| (h.snapshot.clone(), h.bytes))
.collect(),
future: document
.future
.iter()
.map(|h| (h.snapshot.clone(), h.bytes))
.collect(),
revision: document.revision,
current_bytes: document.current_bytes,
recovered: document.recovered_unsaved,
}
}
fn assert(&self, document: &Document) {
assert!(Arc::ptr_eq(&self.current, &document.current));
match (&self.saved, &document.saved) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("saved baseline changed"),
}
for (before, after) in [
(&self.past, &document.past),
(&self.future, &document.future),
] {
assert_eq!(before.len(), after.len());
for ((snapshot, bytes), entry) in before.iter().zip(after) {
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
assert_eq!(*bytes, entry.bytes);
}
}
assert_eq!(self.revision, document.revision);
assert_eq!(self.current_bytes, document.current_bytes);
assert_eq!(self.recovered, document.recovered_unsaved);
}
}
#[test]
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
// Literal role-specific replacements are independent of the updater/tokenizer.
// They deliberately exclude prose, historical review text and URI spelling.
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
let cases: &[AliasCase<'_>] = &[
(
1,
"mission",
"renamed",
&[("project mission ", "project renamed ")],
),
(
2,
"assess",
"renamed",
&[
("oa_activity assess ", "oa_activity renamed "),
("\r\n assess;", "\r\n renamed;"),
("@assess;", "@renamed;"),
],
),
(
5,
"transfer",
"renamed",
&[
("interaction transfer ", "interaction renamed "),
("\r\n transfer;", "\r\n renamed;"),
],
),
(
6,
"review",
"renamed",
&[
("trace review ", "trace renamed "),
("owner @review;", "owner @renamed;"),
],
),
(
7,
"evidence",
"renamed",
&[("artifact evidence ", "artifact renamed ")],
),
(
8,
"overview",
"renamed",
&[
("diagram overview ", "diagram renamed "),
("diagram @overview;", "diagram @renamed;"),
],
),
(
9,
"placement",
"renamed",
&[("node placement ", "node renamed ")],
),
];
for &(number, old, new, replacements) in cases {
let mut document = fixture();
let original = document.snapshot();
let mut expected_model = document.project().clone();
// Explicit per-family oracle, separate from production expectation helper.
match number {
1 => expected_model.alias = new.into(),
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
9 => {
expected_model
.diagrams
.get_mut(&id(8))
.unwrap()
.nodes
.get_mut(&id(9))
.unwrap()
.alias = new.into()
}
_ => unreachable!(),
}
let mut expected_bundle = document.bundle().clone();
for source in expected_bundle.files.values_mut() {
for &(before, after) in replacements {
*source = source.replace(before, after);
}
}
let state = Unchanged::capture(&document);
let prepared = prepare(&document, id(number), new);
state.assert(&document);
assert!(prepared.changed());
assert_eq!(prepared.old_alias(), old);
assert_eq!(prepared.new_alias(), new);
assert_eq!(prepared.target(), id(number));
assert_eq!(prepared.revision(), 0);
assert_eq!(prepared.before().bundle, original.bundle);
assert_eq!(prepared.after().bundle, expected_bundle);
assert_eq!(prepared.after().decoded.project, expected_model);
assert!(document.commit_alias_rename(&prepared).unwrap());
assert!(
Arc::ptr_eq(&document.current, &prepared.0.after),
"adopt the exact prepared Arc"
);
assert_eq!(document.past.len(), 1);
assert!(document.dirty());
let after = Unchanged::capture(&document);
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
after.assert(&document);
assert!(document.undo());
assert!(Arc::ptr_eq(&document.current, &original));
assert!(!document.dirty());
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.bundle(), &expected_bundle);
assert_eq!(document.project(), &expected_model);
document.mark_saved(document.snapshot());
let reopened =
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected_model);
assert_eq!(reopened.bundle(), &expected_bundle);
assert!(!reopened.dirty());
}
}
#[test]
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
assert!(matches!(
language::tokenize("oa_activity")[0].class,
language::TokenClass::Keyword
));
let mut document = fixture();
let prepared = prepare(&document, id(2), "oa_activity");
document.commit_alias_rename(&prepared).unwrap();
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
}
#[test]
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
let mut document = fixture();
let first = prepare(&document, id(2), "changed");
document.commit_alias_rename(&first).unwrap();
assert!(document.undo());
document.invalidate_saved_baseline();
document.mark_recovered();
let state = Unchanged::capture(&document);
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
assert!(
document
.alias_rename_request(id(2), replacement.into())
.unwrap()
.prepare()
.is_err()
);
state.assert(&document);
}
assert!(
document
.alias_rename_request(Id::nil(), "new".into())
.is_err()
);
let no_op = prepare(&document, id(2), "assess");
assert!(!no_op.changed());
assert!(!document.commit_alias_rename(&no_op).unwrap());
state.assert(&document);
assert!(document.redo());
assert_eq!(document.project().elements[&id(2)].alias, "changed");
}
#[test]
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
let mut document = fixture();
let old = prepare(&document, id(2), "renamed");
let mut second = fixture();
let second_state = Unchanged::capture(&second);
assert!(second.commit_alias_rename(&old).is_err());
second_state.assert(&second);
// Even an identical shared accepted Arc cannot confer this Document's owner.
second.current = document.current.clone();
let second_state = Unchanged::capture(&second);
assert!(second.commit_alias_rename(&old).is_err());
second_state.assert(&second);
document
.execute(Command::Rename(id(2), "Later name".into()))
.unwrap();
let edited = Unchanged::capture(&document);
assert!(document.commit_alias_rename(&old).is_err());
edited.assert(&document);
assert!(document.undo());
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
let undone = Unchanged::capture(&document);
assert!(document.commit_alias_rename(&old).is_err());
undone.assert(&document);
document.revision = u64::MAX - 1;
let last = prepare(&document, id(2), "renamed");
document
.execute(Command::Rename(id(2), "Exhaust revision".into()))
.unwrap();
document.undo();
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
let exhausted = Unchanged::capture(&document);
assert!(document.commit_alias_rename(&last).is_err());
assert!(
document
.alias_rename_request(id(2), "renamed".into())
.is_err()
);
assert!(
document
.alias_rename_request(id(2), "assess".into())
.is_err()
);
exhausted.assert(&document);
}
#[test]
fn preview_retains_latest_saved_baseline_and_recovery_state() {
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
let mut document = fixture();
let prepared = prepare(&document, id(2), "renamed");
let original = document.snapshot();
match baseline {
"saved_before" => document.mark_saved(original.clone()),
"unknown" => document.invalidate_saved_baseline(),
"raw_repair" => {
let mut raw = document.bundle().clone();
raw.files
.insert("project.arcadia".into(), "invalid repair source".into());
document.bind_repair_baseline(raw);
}
"recovered" => document.mark_recovered(),
_ => unreachable!(),
}
let saved = document.saved.clone();
let recovered = document.recovered_unsaved;
assert!(document.commit_alias_rename(&prepared).unwrap());
assert_eq!(document.saved, saved);
assert_eq!(document.recovered_unsaved, recovered);
assert!(document.dirty());
assert!(document.undo());
assert_eq!(document.saved, saved);
assert_eq!(document.recovered_unsaved, recovered);
assert_eq!(document.dirty(), baseline != "saved_before");
}
// A completed save after Preview remains authoritative, including when it
// clears recovery; a worker must not restore its earlier state on Confirm.
let mut document = fixture();
document.mark_recovered();
document.invalidate_saved_baseline();
let prepared = prepare(&document, id(2), "renamed");
document.mark_saved(document.snapshot());
document.commit_alias_rename(&prepared).unwrap();
assert!(!document.recovered_unsaved);
document.undo();
assert!(!document.dirty());
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
document.redo();
document.mark_saved(document.snapshot());
document.undo();
let return_to_saved = prepare(&document, id(2), "renamed");
document.commit_alias_rename(&return_to_saved).unwrap();
assert!(!document.dirty());
assert!(!document.can_redo());
}
#[test]
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
let mut document = fixture();
let prepared = prepare(&document, id(2), "renamed");
let exact = prepared.after().clone();
let mut counterfeit = prepared.after().clone();
counterfeit.decoded.project.name = "Injected unrelated edit".into();
counterfeit.index.declarations.clear();
let unchanged = Unchanged::capture(&document);
assert!(document.apply_compiled(counterfeit).is_err());
unchanged.assert(&document);
let mut unrelated = exact.decoded.project.clone();
unrelated.name = "A self-consistent unrelated candidate".into();
let forged_dto = source_project::compile_bundle(
&source_project::format_bundle(&unrelated),
ProjectLimits::default(),
)
.unwrap();
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
// leaves the opaque capability and its exact adopted model/source intact.
document.commit_alias_rename(&prepared).unwrap();
assert_eq!(document.project(), &exact.decoded.project);
assert_eq!(document.bundle(), &exact.bundle);
}
#[test]
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
let mut document = fixture();
let mut bundle = document.bundle().clone();
let padding = HISTORY_BYTES / 2 - document.current_bytes;
bundle
.files
.get_mut("views.arcadia")
.unwrap()
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
document.apply_bundle(bundle).unwrap();
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
// A same-length alias retains the exact estimate; older history is evictable.
let boundary = prepare(&document, id(2), "rating");
assert_eq!(
boundary.0.before_bytes + boundary.0.after_bytes,
HISTORY_BYTES
);
let unchanged = Unchanged::capture(&document);
assert!(
document
.alias_rename_request(id(2), "longer_alias".into())
.unwrap()
.prepare()
.is_err()
);
unchanged.assert(&document);
document.commit_alias_rename(&boundary).unwrap();
assert_eq!(document.past.len(), 1);
assert!(document.undo());
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
assert!(!document.can_undo());
assert!(document.redo());
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
assert!(check_history_capacity(usize::MAX, 1).is_err());
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
}
#[test]
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
let mut document = fixture();
let prepared = prepare(&document, id(2), "renamed");
// Inject cache corruption to falsify the commit-side check independently of
// the preparation admission. Normal edits also change revision and Arc.
document.current_bytes = HISTORY_BYTES;
let unchanged = Unchanged::capture(&document);
assert!(
document
.alias_rename_request(id(2), "renamed".into())
.is_err()
);
assert!(document.commit_alias_rename(&prepared).is_err());
// No-op intent needs no Undo reservation even at the admission boundary.
assert!(
document
.alias_rename_request(id(2), "assess".into())
.is_ok()
);
unchanged.assert(&document);
}
File diff suppressed because it is too large Load Diff
-3
View File
@@ -6,7 +6,6 @@ mod connection_tool;
mod context_menu;
mod data_values;
mod data_view;
mod diagnostic_input;
mod diagram;
mod document;
mod explorer;
@@ -24,9 +23,7 @@ mod port_workflow;
mod recovery;
mod route_edit;
mod semantic_review;
mod source_annotations;
mod source_navigation;
mod source_rename;
mod source_tree;
mod source_workspace;
mod style;
@@ -1,147 +1,147 @@
use super::artifacts::{Editor, Event, Raw};
use super::*;
use iced::{
Element as View, Fill,
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
};
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
for (layer, label, selected) in [
(Layer::Oa, "Operational", editor.oa.clone()),
(Layer::Sa, "System", editor.sa.clone()),
] {
content = content.push(text(label).size(12)).push(
row![
pick_list(editor.choices(p, trace, layer), selected, move |v| {
Event::Pick(layer, Some(v))
})
.placeholder("Choose exact artifact identity")
.width(Fill)
.padding(7),
button("No identity")
.padding(7)
.on_press(Event::Pick(layer, None))
]
.spacing(7),
);
}
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
p: &'a Project,
facet: &'a FacetSelector,
) -> Option<View<'a, Event>> {
let value = editor.drafts.get(facet)?;
let (_, which) = artifacts::parts(facet)?;
let mut content =
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
match value {
Raw::Text(_) if which == ArtifactFacet::Description => {
let draft = editor.descriptions.get(facet)?;
let facet = facet.clone();
content = content.push(
text_editor(draft)
.height(110)
.on_action(move |action| Event::Description(facet.clone(), action)),
);
}
Raw::Text(value) => {
let facet = facet.clone();
content = content.push(
text_input("Exact text", value)
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
.padding(7),
);
}
Raw::Owner(value) => {
let mut choices: Vec<_> = p
.identities()
.into_iter()
.map(|(id, alias)| Choice {
id,
label: format!("{} · @{alias}", p.named(id)),
})
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
let raw_facet = facet.clone();
let pick_facet = facet.clone();
content = content
.push(
pick_list(choices, selected, move |v: Choice| {
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
})
.placeholder("Explicit final owner")
.width(Fill)
.padding(7),
)
.push(
text_input("Owner identity (retains rejected input)", value)
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
.padding(7),
);
}
Raw::Relation(value) => {
let facet = facet.clone();
let variants = [
ArtifactRelation::References,
ArtifactRelation::Satisfies,
ArtifactRelation::Verifies,
ArtifactRelation::Implements,
ArtifactRelation::DerivedFrom,
];
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
content = content.push(
pick_list(choices, Some(RelationChoice(*value)), move |v| {
Event::Input(facet.clone(), Raw::Relation(v.0))
})
.padding(7),
);
}
Raw::Optional {
present,
text: value,
} => {
let toggle_facet = facet.clone();
let retained = value.clone();
let input_facet = facet.clone();
let enabled = *present;
content = content
.push(
checkbox(*present)
.label("Present (checked with empty text means an explicit empty value)")
.on_toggle(move |present| {
Event::Input(
toggle_facet.clone(),
Raw::Optional {
present,
text: retained.clone(),
},
)
}),
)
.push(
text_input("Retained optional text", value)
.on_input(move |text| {
Event::Input(
input_facet.clone(),
Raw::Optional {
present: enabled,
text,
},
)
})
.padding(7),
);
}
}
Some(content.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RelationChoice(ArtifactRelation);
impl std::fmt::Display for RelationChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
use super::artifacts::{Editor, Event, Raw};
use super::*;
use iced::{
Element as View, Fill,
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
};
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
for (layer, label, selected) in [
(Layer::Oa, "Operational", editor.oa.clone()),
(Layer::Sa, "System", editor.sa.clone()),
] {
content = content.push(text(label).size(12)).push(
row![
pick_list(editor.choices(p, trace, layer), selected, move |v| {
Event::Pick(layer, Some(v))
})
.placeholder("Choose exact artifact identity")
.width(Fill)
.padding(7),
button("No identity")
.padding(7)
.on_press(Event::Pick(layer, None))
]
.spacing(7),
);
}
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
p: &'a Project,
facet: &'a FacetSelector,
) -> Option<View<'a, Event>> {
let value = editor.drafts.get(facet)?;
let (_, which) = artifacts::parts(facet)?;
let mut content =
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
match value {
Raw::Text(_) if which == ArtifactFacet::Description => {
let draft = editor.descriptions.get(facet)?;
let facet = facet.clone();
content = content.push(
text_editor(draft)
.height(110)
.on_action(move |action| Event::Description(facet.clone(), action)),
);
}
Raw::Text(value) => {
let facet = facet.clone();
content = content.push(
text_input("Exact text", value)
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
.padding(7),
);
}
Raw::Owner(value) => {
let mut choices: Vec<_> = p
.identities()
.into_iter()
.map(|(id, alias)| Choice {
id,
label: format!("{} · @{alias}", p.named(id)),
})
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
let raw_facet = facet.clone();
let pick_facet = facet.clone();
content = content
.push(
pick_list(choices, selected, move |v: Choice| {
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
})
.placeholder("Explicit final owner")
.width(Fill)
.padding(7),
)
.push(
text_input("Owner identity (retains rejected input)", value)
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
.padding(7),
);
}
Raw::Relation(value) => {
let facet = facet.clone();
let variants = [
ArtifactRelation::References,
ArtifactRelation::Satisfies,
ArtifactRelation::Verifies,
ArtifactRelation::Implements,
ArtifactRelation::DerivedFrom,
];
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
content = content.push(
pick_list(choices, Some(RelationChoice(*value)), move |v| {
Event::Input(facet.clone(), Raw::Relation(v.0))
})
.padding(7),
);
}
Raw::Optional {
present,
text: value,
} => {
let toggle_facet = facet.clone();
let retained = value.clone();
let input_facet = facet.clone();
let enabled = *present;
content = content
.push(
checkbox(*present)
.label("Present (checked with empty text means an explicit empty value)")
.on_toggle(move |present| {
Event::Input(
toggle_facet.clone(),
Raw::Optional {
present,
text: retained.clone(),
},
)
}),
)
.push(
text_input("Retained optional text", value)
.on_input(move |text| {
Event::Input(
input_facet.clone(),
Raw::Optional {
present: enabled,
text,
},
)
})
.padding(7),
);
}
}
Some(content.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RelationChoice(ArtifactRelation);
impl std::fmt::Display for RelationChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
@@ -1,79 +1,79 @@
//! Explicit attachment pair and final destination field controls.
use super::{
attachments::{Editor, Event, Family},
*,
};
use iced::{
Element as View, Fill,
widget::{button, column, pick_list, row, text, text_input},
};
pub(super) fn pairs<'a>(
editor: &'a Editor,
project: &'a Project,
trace: &'a Trace,
) -> View<'a, Event> {
let mut content = column![
text("Existing children and relationship endpoints").size(16),
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
].spacing(9);
for layer in [Layer::Oa, Layer::Sa] {
let label = if layer == Layer::Oa {
"Operational"
} else {
"System"
};
let owner = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
content = content.push(
row![
pick_list(
editor.choices(project, trace, layer),
editor.selection(layer).cloned(),
move |choice| Event::Pick(layer, Some(choice))
)
.placeholder(format!("Choose an existing {label} identity"))
.text_size(12)
.padding(7)
.width(Fill),
button(text("No identity on this side").size(11))
.style(crate::style::nav)
.padding(7)
.on_press(Event::Pick(layer, None)),
]
.spacing(7),
);
}
content = content.push(
button(text("Use exact attachment pair").size(12))
.padding(8)
.style(crate::style::nav)
.on_press(Event::Stage),
);
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
project: &'a Project,
facet: &'a FacetSelector,
layer: Layer,
) -> Option<View<'a, Message>> {
let draft = editor.drafts.get(facet)?;
let (id, _) = attachments::actual_slot(facet, layer)?;
let path = attachments::path(project, facet, layer)?;
Some(
column![
text(format!("Destination record: {}", project.named(id))).size(12),
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
]
.spacing(7)
.into(),
)
}
//! Explicit attachment pair and final destination field controls.
use super::{
attachments::{Editor, Event, Family},
*,
};
use iced::{
Element as View, Fill,
widget::{button, column, pick_list, row, text, text_input},
};
pub(super) fn pairs<'a>(
editor: &'a Editor,
project: &'a Project,
trace: &'a Trace,
) -> View<'a, Event> {
let mut content = column![
text("Existing children and relationship endpoints").size(16),
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
].spacing(9);
for layer in [Layer::Oa, Layer::Sa] {
let label = if layer == Layer::Oa {
"Operational"
} else {
"System"
};
let owner = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
content = content.push(
row![
pick_list(
editor.choices(project, trace, layer),
editor.selection(layer).cloned(),
move |choice| Event::Pick(layer, Some(choice))
)
.placeholder(format!("Choose an existing {label} identity"))
.text_size(12)
.padding(7)
.width(Fill),
button(text("No identity on this side").size(11))
.style(crate::style::nav)
.padding(7)
.on_press(Event::Pick(layer, None)),
]
.spacing(7),
);
}
content = content.push(
button(text("Use exact attachment pair").size(12))
.padding(8)
.style(crate::style::nav)
.on_press(Event::Stage),
);
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
}
pub(super) fn candidate<'a>(
editor: &'a Editor,
project: &'a Project,
facet: &'a FacetSelector,
layer: Layer,
) -> Option<View<'a, Message>> {
let draft = editor.drafts.get(facet)?;
let (id, _) = attachments::actual_slot(facet, layer)?;
let path = attachments::path(project, facet, layer)?;
Some(
column![
text(format!("Destination record: {}", project.named(id))).size(12),
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
]
.spacing(7)
.into(),
)
}
+350 -350
View File
@@ -1,350 +1,350 @@
//! Closed presence-only adapter. The core independently validates the full final record.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Family {
Member(ReferenceCollection),
Literal,
Annotation,
Binding,
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
Self::Literal => "Enumeration literals",
Self::Annotation => "Message values",
Self::Binding => "Scenario lifeline bindings",
})
}
}
pub(super) const FAMILIES: [Family; 13] = [
Family::Member(ReferenceCollection::Steps),
Family::Member(ReferenceCollection::FragmentBranches),
Family::Member(ReferenceCollection::FunctionInputItems),
Family::Member(ReferenceCollection::FunctionOutputItems),
Family::Member(ReferenceCollection::SignalItems),
Family::Member(ReferenceCollection::ExchangeItems),
Family::Member(ReferenceCollection::CategoryExchanges),
Family::Member(ReferenceCollection::TransitionTriggers),
Family::Member(ReferenceCollection::TransitionEffects),
Family::Member(ReferenceCollection::Constrained),
Family::Literal,
Family::Annotation,
Family::Binding,
];
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
let family = match facet {
FacetSelector::Member { collection, .. } => Family::Member(*collection),
FacetSelector::Literal(_) => Family::Literal,
FacetSelector::Annotation(_) => Family::Annotation,
FacetSelector::Binding(_) => Family::Binding,
_ => return None,
};
FAMILIES.contains(&family).then_some(family)
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
family(facet).is_some()
}
// Fixed paths for the closed collections and the exact enum literal set.
// They are selected by the typed facet; callers cannot supply arbitrary paths.
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
let tails: &[&str] = match family {
Family::Member(ReferenceCollection::Steps) => {
return [
"/body/Oa/Process/steps",
"/body/Sa/FunctionalChain/steps",
"/body/Oa/Shared/Scenario/steps",
"/body/Sa/Shared/Scenario/steps",
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
]
.into_iter()
.find(|p| value.pointer(p).is_some_and(Value::is_array))
.map(str::to_owned);
}
Family::Member(ReferenceCollection::FragmentBranches) => {
&["Occurrence/body/Fragment/branches"]
}
Family::Member(ReferenceCollection::FunctionInputItems) => {
&["Port/contract/FunctionInput/incoming_items"]
}
Family::Member(ReferenceCollection::FunctionOutputItems) => {
&["Port/contract/FunctionOutput/outgoing_items"]
}
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
Family::Literal => &["DataType/definition/Enumeration/literals"],
Family::Annotation => &["Occurrence/body/Exchange/values"],
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
Family::Member(ReferenceCollection::ExchangeItems) => {
return [
"Interaction",
"FunctionalExchange",
"BoundaryExchange",
"CommunicationMean",
]
.iter()
.map(|kind| format!("/body/{kind}/items"))
.find(|p| value.pointer(p).is_some_and(Value::is_array));
}
Family::Member(ReferenceCollection::TransitionTriggers) => {
return value
.pointer("/body/StateTransition/triggers")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/triggers".into());
}
Family::Member(ReferenceCollection::TransitionEffects) => {
return value
.pointer("/body/StateTransition/effects")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/effects".into());
}
};
prefixes
.iter()
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
.find(|p| value.pointer(p).is_some_and(Value::is_array))
}
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Id")),
FacetSelector::Literal(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|v| Value::String(v.clone())),
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|key| serde_json::to_value(key).expect("Annotation key")),
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Binding key")),
_ => None,
}
}
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
if family(a).is_none() || family(a) != family(b) {
return false;
}
[Layer::Oa, Layer::Sa]
.into_iter()
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
}
pub(super) fn project_presence(
value: &mut Value,
facet: &FacetSelector,
layer: Layer,
present: bool,
) -> Result<(), String> {
let family = family(facet).ok_or("This keyed family remains read-only.")?;
let key = key(facet, layer)
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
let path =
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
let list = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination collection is unavailable.")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|v| v != &key);
}
Ok(())
}
impl ReviewRow {
pub(super) fn keyed_value(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if keyed_rows::supported(facet) {
return self.project_row(project, trace, value, facet, decision);
}
let present = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::TakeSource => {
// A missing source identity is a target-local row, not a removal.
if key(facet, self.direction.source_layer()).is_none() {
return Ok(());
}
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
}
SemanticDecision::Candidate => *self
.presence
.get(facet)
.ok_or("Choose final membership presence.")?,
};
project_presence(value, facet, self.direction.target_layer(), present)
}
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let values: Vec<_> = [trace.oa, trace.sa]
.into_iter()
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
.collect();
FAMILIES
.into_iter()
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
.collect()
}
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
if self.member_family == Family::Binding {
return self.binding_choices(project, layer, true);
}
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
let Some(mut draft) = Draft::from_project(project, id) else {
return vec![];
};
if layer == self.direction.target_layer() {
draft.set_context(self.combined_value(project, true).ok());
}
let selected = if layer == Layer::Oa {
&self.member_oa
} else {
&self.member_sa
};
let Some(path) = path(&draft.context_value(), self.member_family) else {
return selected.iter().cloned().collect();
};
crate::forms::reference_choices(
&draft,
project,
&format!("{path}/0"),
selected.as_ref().map_or(Id::nil(), |c| c.id),
)
.into_iter()
.filter(|c| !c.id.is_nil())
.collect()
}
pub(super) fn edit_pair(
&mut self,
project: &Project,
facet: FacetSelector,
) -> Result<(), String> {
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
match facet {
FacetSelector::Annotation(keys) => {
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
}
FacetSelector::Binding(keys) => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Member { keys, .. } => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Literal(keys) => {
self.literal_oa = keys.oa.is_some();
self.literal_sa = keys.sa.is_some();
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
}
_ => unreachable!(),
}
self.tab = Tab::Members;
Ok(())
}
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
if !FAMILIES.contains(&self.member_family) {
return Err("This keyed family remains read-only.".into());
}
let facet = match self.member_family {
Family::Member(collection) => FacetSelector::Member {
collection,
keys: KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
},
},
Family::Literal => FacetSelector::Literal(KeyPair {
oa: self.literal_oa.then(|| self.literal.clone()),
sa: self.literal_sa.then(|| self.literal.clone()),
}),
Family::Annotation => FacetSelector::Annotation(KeyPair {
oa: self
.annotation_oa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
sa: self
.annotation_sa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
}),
Family::Binding => FacetSelector::Binding(KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
}),
};
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
capture_facet(project, trace.oa, trace.sa, &facet)?;
self.stage_row_references(&facet)?;
if let FacetSelector::Member {
keys:
KeyPair {
oa: Some(oa),
sa: Some(sa),
},
..
} = &facet
{
self.references.insert(*oa, *sa);
}
self.staged.insert(facet.clone());
self.selected = Some(facet);
self.tab = Tab::Facts;
Ok(())
}
}
//! Closed presence-only adapter. The core independently validates the full final record.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Family {
Member(ReferenceCollection),
Literal,
Annotation,
Binding,
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
Self::Literal => "Enumeration literals",
Self::Annotation => "Message values",
Self::Binding => "Scenario lifeline bindings",
})
}
}
pub(super) const FAMILIES: [Family; 13] = [
Family::Member(ReferenceCollection::Steps),
Family::Member(ReferenceCollection::FragmentBranches),
Family::Member(ReferenceCollection::FunctionInputItems),
Family::Member(ReferenceCollection::FunctionOutputItems),
Family::Member(ReferenceCollection::SignalItems),
Family::Member(ReferenceCollection::ExchangeItems),
Family::Member(ReferenceCollection::CategoryExchanges),
Family::Member(ReferenceCollection::TransitionTriggers),
Family::Member(ReferenceCollection::TransitionEffects),
Family::Member(ReferenceCollection::Constrained),
Family::Literal,
Family::Annotation,
Family::Binding,
];
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
let family = match facet {
FacetSelector::Member { collection, .. } => Family::Member(*collection),
FacetSelector::Literal(_) => Family::Literal,
FacetSelector::Annotation(_) => Family::Annotation,
FacetSelector::Binding(_) => Family::Binding,
_ => return None,
};
FAMILIES.contains(&family).then_some(family)
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
family(facet).is_some()
}
// Fixed paths for the closed collections and the exact enum literal set.
// They are selected by the typed facet; callers cannot supply arbitrary paths.
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
let tails: &[&str] = match family {
Family::Member(ReferenceCollection::Steps) => {
return [
"/body/Oa/Process/steps",
"/body/Sa/FunctionalChain/steps",
"/body/Oa/Shared/Scenario/steps",
"/body/Sa/Shared/Scenario/steps",
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
]
.into_iter()
.find(|p| value.pointer(p).is_some_and(Value::is_array))
.map(str::to_owned);
}
Family::Member(ReferenceCollection::FragmentBranches) => {
&["Occurrence/body/Fragment/branches"]
}
Family::Member(ReferenceCollection::FunctionInputItems) => {
&["Port/contract/FunctionInput/incoming_items"]
}
Family::Member(ReferenceCollection::FunctionOutputItems) => {
&["Port/contract/FunctionOutput/outgoing_items"]
}
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
Family::Literal => &["DataType/definition/Enumeration/literals"],
Family::Annotation => &["Occurrence/body/Exchange/values"],
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
Family::Member(ReferenceCollection::ExchangeItems) => {
return [
"Interaction",
"FunctionalExchange",
"BoundaryExchange",
"CommunicationMean",
]
.iter()
.map(|kind| format!("/body/{kind}/items"))
.find(|p| value.pointer(p).is_some_and(Value::is_array));
}
Family::Member(ReferenceCollection::TransitionTriggers) => {
return value
.pointer("/body/StateTransition/triggers")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/triggers".into());
}
Family::Member(ReferenceCollection::TransitionEffects) => {
return value
.pointer("/body/StateTransition/effects")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/effects".into());
}
};
prefixes
.iter()
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
.find(|p| value.pointer(p).is_some_and(Value::is_array))
}
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Id")),
FacetSelector::Literal(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|v| Value::String(v.clone())),
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|key| serde_json::to_value(key).expect("Annotation key")),
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Binding key")),
_ => None,
}
}
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
if family(a).is_none() || family(a) != family(b) {
return false;
}
[Layer::Oa, Layer::Sa]
.into_iter()
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
}
pub(super) fn project_presence(
value: &mut Value,
facet: &FacetSelector,
layer: Layer,
present: bool,
) -> Result<(), String> {
let family = family(facet).ok_or("This keyed family remains read-only.")?;
let key = key(facet, layer)
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
let path =
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
let list = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination collection is unavailable.")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|v| v != &key);
}
Ok(())
}
impl ReviewRow {
pub(super) fn keyed_value(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if keyed_rows::supported(facet) {
return self.project_row(project, trace, value, facet, decision);
}
let present = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::TakeSource => {
// A missing source identity is a target-local row, not a removal.
if key(facet, self.direction.source_layer()).is_none() {
return Ok(());
}
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
}
SemanticDecision::Candidate => *self
.presence
.get(facet)
.ok_or("Choose final membership presence.")?,
};
project_presence(value, facet, self.direction.target_layer(), present)
}
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let values: Vec<_> = [trace.oa, trace.sa]
.into_iter()
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
.collect();
FAMILIES
.into_iter()
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
.collect()
}
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
if self.member_family == Family::Binding {
return self.binding_choices(project, layer, true);
}
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
let Some(mut draft) = Draft::from_project(project, id) else {
return vec![];
};
if layer == self.direction.target_layer() {
draft.set_context(self.combined_value(project, true).ok());
}
let selected = if layer == Layer::Oa {
&self.member_oa
} else {
&self.member_sa
};
let Some(path) = path(&draft.context_value(), self.member_family) else {
return selected.iter().cloned().collect();
};
crate::forms::reference_choices(
&draft,
project,
&format!("{path}/0"),
selected.as_ref().map_or(Id::nil(), |c| c.id),
)
.into_iter()
.filter(|c| !c.id.is_nil())
.collect()
}
pub(super) fn edit_pair(
&mut self,
project: &Project,
facet: FacetSelector,
) -> Result<(), String> {
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
match facet {
FacetSelector::Annotation(keys) => {
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
}
FacetSelector::Binding(keys) => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Member { keys, .. } => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Literal(keys) => {
self.literal_oa = keys.oa.is_some();
self.literal_sa = keys.sa.is_some();
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
}
_ => unreachable!(),
}
self.tab = Tab::Members;
Ok(())
}
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
if !FAMILIES.contains(&self.member_family) {
return Err("This keyed family remains read-only.".into());
}
let facet = match self.member_family {
Family::Member(collection) => FacetSelector::Member {
collection,
keys: KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
},
},
Family::Literal => FacetSelector::Literal(KeyPair {
oa: self.literal_oa.then(|| self.literal.clone()),
sa: self.literal_sa.then(|| self.literal.clone()),
}),
Family::Annotation => FacetSelector::Annotation(KeyPair {
oa: self
.annotation_oa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
sa: self
.annotation_sa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
}),
Family::Binding => FacetSelector::Binding(KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
}),
};
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
capture_facet(project, trace.oa, trace.sa, &facet)?;
self.stage_row_references(&facet)?;
if let FacetSelector::Member {
keys:
KeyPair {
oa: Some(oa),
sa: Some(sa),
},
..
} = &facet
{
self.references.insert(*oa, *sa);
}
self.staged.insert(facet.clone());
self.selected = Some(facet);
self.tab = Tab::Facts;
Ok(())
}
}
@@ -1,345 +1,345 @@
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
use super::*;
use crate::data_values::{FieldPathChoice, FieldPathKey};
#[derive(Default)]
pub struct AddressDraft {
pub selected: Option<FieldPathKey>,
pub scope: Option<FieldPathKey>,
pub page: usize,
}
#[derive(Default)]
pub struct RowDraft {
pub expression: String,
pub editor: text_editor::Content,
pub local: Option<Choice>,
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
)
}
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
match facet {
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
}),
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
oa: if layer == Layer::Oa { keys.oa } else { None },
sa: if layer == Layer::Sa { keys.sa } else { None },
}),
_ => facet.clone(),
}
}
impl ReviewRow {
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
if layer == Layer::Oa {
&self.annotation_oa
} else {
&self.annotation_sa
}
}
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
if layer == Layer::Oa {
&mut self.annotation_oa
} else {
&mut self.annotation_sa
}
}
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
let trace = self.effective_trace(project)?;
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
if layer == self.direction.target_layer()
&& let Ok(value) = self.combined_value(project, true)
&& let Ok(element) = serde_json::from_value(value)
{
return Some(element);
}
project.elements.get(&id).cloned()
}
pub(super) fn annotation_choices(
&self,
project: &Project,
layer: Layer,
) -> Result<Vec<FieldPathChoice>, String> {
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
..
},
..
}) = element.shared()
else {
return Err(
"Choose an exchange on this message before selecting a carried field.".into(),
);
};
let exchange = project
.relations
.get(exchange)
.ok_or("The message exchange is unavailable.")?;
crate::data_values::at_scope(
project,
exchange,
message_kind,
self.annotation_address(layer).scope.as_ref(),
)
}
pub(super) fn binding_choices(
&self,
project: &Project,
layer: Layer,
referenced: bool,
) -> Vec<Choice> {
let Some(element) = self.row_owner(project, layer) else {
return vec![];
};
let scenario = if referenced {
match element.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { scenario, .. },
..
}) => *scenario,
_ => None,
}
} else {
element
.parent
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
};
let mut choices: Vec<_> = project
.elements
.values()
.filter(|e| {
e.kind() == Kind::Lifeline
&& e.layer() == layer
&& scenario.is_some()
&& e.parent == scenario
})
.map(|e| view::choice(project, e.id))
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
choices
}
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
let pairs: Vec<_> = match facet {
FacetSelector::Annotation(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => {
if oa.path.len() != sa.path.len() {
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
}
oa.path
.iter()
.chain(std::iter::once(&oa.field))
.copied()
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
.collect()
}
FacetSelector::Binding(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => vec![(*oa, *sa)],
_ => vec![],
};
// A repeated recursive field may occur several times in one address.
// Never let the last occurrence silently replace a conflicting pair.
let mut supplied = BTreeMap::new();
for (oa, sa) in pairs {
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
return Err(
"This address pairs the same Operational field with different System fields."
.into(),
);
}
}
self.references.extend(supplied);
Ok(())
}
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
if self.row_drafts.contains_key(facet) {
return;
}
let value = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
let present = value.as_ref().is_some_and(|v| !v.is_null());
let mut draft = RowDraft::default();
if let Some(value) = value {
draft.expression = value["expression"].as_str().unwrap_or_default().into();
draft.editor = text_editor::Content::with_text(&draft.expression);
draft.local = value["local"]
.as_str()
.and_then(|v| v.parse().ok())
.map(|id| view::choice(project, id));
}
self.presence.entry(facet.clone()).or_insert(present);
self.row_drafts.insert(facet.clone(), draft);
}
pub(super) fn edit_row(
&mut self,
facet: &FacetSelector,
action: impl FnOnce(&mut RowDraft),
) -> Result<(), String> {
if !supported(facet)
|| self.selected.as_ref() != Some(facet)
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select this row and choose Set final value before editing it.".into());
}
action(
self.row_drafts
.get_mut(facet)
.ok_or("The row draft is unavailable.")?,
);
Ok(())
}
pub(super) fn project_row(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if decision == SemanticDecision::KeepTarget {
return Ok(());
}
let source_layer = self.direction.source_layer();
let target_layer = self.direction.target_layer();
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
return Ok(());
}
let target_key = keyed::key(facet, target_layer)
.ok_or("Choose the destination row address before setting or taking its value.")?;
let row = if decision == SemanticDecision::Candidate {
if !self.presence.get(facet).copied().unwrap_or(false) {
Value::Null
} else {
let draft = self
.row_drafts
.get(facet)
.ok_or("Set the final row value.")?;
match facet {
FacetSelector::Annotation(_) => {
let key: AnnotationKey = serde_json::from_value(target_key.clone())
.map_err(|e| e.to_string())?;
serde_json::to_value(MessageValue {
path: key.path,
field: key.field,
expression: draft.expression.clone(),
})
.expect("Message value")
}
FacetSelector::Binding(_) => {
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
}
_ => return Err("Unsupported row.".into()),
}
}
} else {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
let row = scalar::value(&fact, source_layer)
.ok_or("Source row collection is not applicable; review its constructor first.")?;
if row.is_null() {
row
} else {
let map = |id: Id| -> Result<Id, String> {
if source_layer == Layer::Oa {
self.references.get(&id).copied().ok_or_else(|| {
format!("Choose a reference counterpart for {}.", project.named(id))
})
} else {
let matches: Vec<_> = self
.references
.iter()
.filter(|(_, sa)| **sa == id)
.map(|(oa, _)| *oa)
.collect();
if matches.len() == 1 {
Ok(matches[0])
} else {
Err(format!(
"Choose one unambiguous reference counterpart for {}.",
project.named(id)
))
}
}
};
match facet {
FacetSelector::Annotation(_) => {
let mut row: MessageValue =
serde_json::from_value(row).map_err(|e| e.to_string())?;
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
row.field = map(row.field)?;
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
!= target_key
{
return Err("Mapped annotation address disagrees with the selected destination address.".into());
}
serde_json::to_value(row).expect("row")
}
FacetSelector::Binding(_) => {
let row: LifelineBinding =
serde_json::from_value(row).map_err(|e| e.to_string())?;
let mapped = LifelineBinding {
referenced: map(row.referenced)?,
local: map(row.local)?,
};
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
}
serde_json::to_value(mapped).expect("row")
}
_ => return Err("Unsupported row.".into()),
}
}
};
let family = keyed::family(facet).ok_or("Unsupported row.")?;
let path = keyed::path(value, family)
.ok_or("The destination constructor has no collection for this row.")?;
let rows = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination rows are unavailable.")?;
let position = rows.iter().position(|row| match facet {
FacetSelector::Annotation(_) => {
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
}
FacetSelector::Binding(_) => row["referenced"] == target_key,
_ => false,
});
match (position, row.is_null()) {
(Some(index), false) => rows[index] = row,
(Some(index), true) => {
rows.remove(index);
}
(None, false) => rows.push(row),
(None, true) => {}
}
Ok(())
}
}
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
use super::*;
use crate::data_values::{FieldPathChoice, FieldPathKey};
#[derive(Default)]
pub struct AddressDraft {
pub selected: Option<FieldPathKey>,
pub scope: Option<FieldPathKey>,
pub page: usize,
}
#[derive(Default)]
pub struct RowDraft {
pub expression: String,
pub editor: text_editor::Content,
pub local: Option<Choice>,
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
)
}
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
match facet {
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
}),
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
oa: if layer == Layer::Oa { keys.oa } else { None },
sa: if layer == Layer::Sa { keys.sa } else { None },
}),
_ => facet.clone(),
}
}
impl ReviewRow {
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
if layer == Layer::Oa {
&self.annotation_oa
} else {
&self.annotation_sa
}
}
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
if layer == Layer::Oa {
&mut self.annotation_oa
} else {
&mut self.annotation_sa
}
}
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
let trace = self.effective_trace(project)?;
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
if layer == self.direction.target_layer()
&& let Ok(value) = self.combined_value(project, true)
&& let Ok(element) = serde_json::from_value(value)
{
return Some(element);
}
project.elements.get(&id).cloned()
}
pub(super) fn annotation_choices(
&self,
project: &Project,
layer: Layer,
) -> Result<Vec<FieldPathChoice>, String> {
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
..
},
..
}) = element.shared()
else {
return Err(
"Choose an exchange on this message before selecting a carried field.".into(),
);
};
let exchange = project
.relations
.get(exchange)
.ok_or("The message exchange is unavailable.")?;
crate::data_values::at_scope(
project,
exchange,
message_kind,
self.annotation_address(layer).scope.as_ref(),
)
}
pub(super) fn binding_choices(
&self,
project: &Project,
layer: Layer,
referenced: bool,
) -> Vec<Choice> {
let Some(element) = self.row_owner(project, layer) else {
return vec![];
};
let scenario = if referenced {
match element.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { scenario, .. },
..
}) => *scenario,
_ => None,
}
} else {
element
.parent
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
};
let mut choices: Vec<_> = project
.elements
.values()
.filter(|e| {
e.kind() == Kind::Lifeline
&& e.layer() == layer
&& scenario.is_some()
&& e.parent == scenario
})
.map(|e| view::choice(project, e.id))
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
choices
}
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
let pairs: Vec<_> = match facet {
FacetSelector::Annotation(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => {
if oa.path.len() != sa.path.len() {
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
}
oa.path
.iter()
.chain(std::iter::once(&oa.field))
.copied()
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
.collect()
}
FacetSelector::Binding(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => vec![(*oa, *sa)],
_ => vec![],
};
// A repeated recursive field may occur several times in one address.
// Never let the last occurrence silently replace a conflicting pair.
let mut supplied = BTreeMap::new();
for (oa, sa) in pairs {
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
return Err(
"This address pairs the same Operational field with different System fields."
.into(),
);
}
}
self.references.extend(supplied);
Ok(())
}
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
if self.row_drafts.contains_key(facet) {
return;
}
let value = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
let present = value.as_ref().is_some_and(|v| !v.is_null());
let mut draft = RowDraft::default();
if let Some(value) = value {
draft.expression = value["expression"].as_str().unwrap_or_default().into();
draft.editor = text_editor::Content::with_text(&draft.expression);
draft.local = value["local"]
.as_str()
.and_then(|v| v.parse().ok())
.map(|id| view::choice(project, id));
}
self.presence.entry(facet.clone()).or_insert(present);
self.row_drafts.insert(facet.clone(), draft);
}
pub(super) fn edit_row(
&mut self,
facet: &FacetSelector,
action: impl FnOnce(&mut RowDraft),
) -> Result<(), String> {
if !supported(facet)
|| self.selected.as_ref() != Some(facet)
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select this row and choose Set final value before editing it.".into());
}
action(
self.row_drafts
.get_mut(facet)
.ok_or("The row draft is unavailable.")?,
);
Ok(())
}
pub(super) fn project_row(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if decision == SemanticDecision::KeepTarget {
return Ok(());
}
let source_layer = self.direction.source_layer();
let target_layer = self.direction.target_layer();
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
return Ok(());
}
let target_key = keyed::key(facet, target_layer)
.ok_or("Choose the destination row address before setting or taking its value.")?;
let row = if decision == SemanticDecision::Candidate {
if !self.presence.get(facet).copied().unwrap_or(false) {
Value::Null
} else {
let draft = self
.row_drafts
.get(facet)
.ok_or("Set the final row value.")?;
match facet {
FacetSelector::Annotation(_) => {
let key: AnnotationKey = serde_json::from_value(target_key.clone())
.map_err(|e| e.to_string())?;
serde_json::to_value(MessageValue {
path: key.path,
field: key.field,
expression: draft.expression.clone(),
})
.expect("Message value")
}
FacetSelector::Binding(_) => {
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
}
_ => return Err("Unsupported row.".into()),
}
}
} else {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
let row = scalar::value(&fact, source_layer)
.ok_or("Source row collection is not applicable; review its constructor first.")?;
if row.is_null() {
row
} else {
let map = |id: Id| -> Result<Id, String> {
if source_layer == Layer::Oa {
self.references.get(&id).copied().ok_or_else(|| {
format!("Choose a reference counterpart for {}.", project.named(id))
})
} else {
let matches: Vec<_> = self
.references
.iter()
.filter(|(_, sa)| **sa == id)
.map(|(oa, _)| *oa)
.collect();
if matches.len() == 1 {
Ok(matches[0])
} else {
Err(format!(
"Choose one unambiguous reference counterpart for {}.",
project.named(id)
))
}
}
};
match facet {
FacetSelector::Annotation(_) => {
let mut row: MessageValue =
serde_json::from_value(row).map_err(|e| e.to_string())?;
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
row.field = map(row.field)?;
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
!= target_key
{
return Err("Mapped annotation address disagrees with the selected destination address.".into());
}
serde_json::to_value(row).expect("row")
}
FacetSelector::Binding(_) => {
let row: LifelineBinding =
serde_json::from_value(row).map_err(|e| e.to_string())?;
let mapped = LifelineBinding {
referenced: map(row.referenced)?,
local: map(row.local)?,
};
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
}
serde_json::to_value(mapped).expect("row")
}
_ => return Err("Unsupported row.".into()),
}
}
};
let family = keyed::family(facet).ok_or("Unsupported row.")?;
let path = keyed::path(value, family)
.ok_or("The destination constructor has no collection for this row.")?;
let rows = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination rows are unavailable.")?;
let position = rows.iter().position(|row| match facet {
FacetSelector::Annotation(_) => {
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
}
FacetSelector::Binding(_) => row["referenced"] == target_key,
_ => false,
});
match (position, row.is_null()) {
(Some(index), false) => rows[index] = row,
(Some(index), true) => {
rows.remove(index);
}
(None, false) => rows.push(row),
(None, true) => {}
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+254 -254
View File
@@ -1,254 +1,254 @@
//! Independent membership and complete final order; the core validates every proposal.
use super::*;
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
match which {
OrderedCollection::Steps => ReferenceCollection::Steps,
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
}
}
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
match which {
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
_ => None,
}
}
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
}
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
Ok(())
}
impl ReviewRow {
pub(super) fn ordered_members(
&self,
project: &Project,
which: OrderedCollection,
) -> Result<Vec<Id>, String> {
read(&self.combine(project, true, true)?, which)
}
pub(super) fn initialize_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
if !self.order_drafts.contains_key(&which) {
let ids = self.ordered_members(project, which)?;
self.order_drafts.insert(which, ids);
}
Ok(())
}
pub(super) fn reconcile_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let members = self.ordered_members(project, which)?;
let draft = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
// This is an explicit user action. Ordinary refresh never discards an authored order.
let mut seen = BTreeSet::new();
draft.retain(|id| members.contains(id) && seen.insert(*id));
for id in members {
if seen.insert(id) {
draft.push(id);
}
}
Ok(())
}
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
let facet = FacetSelector::Order(which);
if self.selected.as_ref() != Some(&facet)
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select Set final value for this order before changing it.".into());
}
Ok(())
}
pub(super) fn move_order(
&mut self,
which: OrderedCollection,
index: usize,
delta: i32,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let ids = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
let destination = match delta {
-1 => index.checked_sub(1),
1 => index.checked_add(1),
_ => None,
};
let destination = destination
.filter(|i| index < ids.len() && *i < ids.len())
.ok_or("Choose an existing adjacent position in the final order.")?;
ids.swap(index, destination);
Ok(())
}
pub(super) fn project_order(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
which: OrderedCollection,
decision: SemanticDecision,
) -> Result<(), String> {
let current = read(value, which)?;
let supplied = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::Candidate => self
.order_drafts
.get(&which)
.cloned()
.ok_or("Choose a final order.")?,
SemanticDecision::TakeSource => {
let fact =
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("The source has no applicable order.")?,
)
.map_err(|e| e.to_string())?;
let map = self.order_reference_map()?;
source
.into_iter()
.map(|id| {
map.get(&id).copied().ok_or_else(|| {
format!(
"Pair source order member {} explicitly in Reference pairs.",
project.named(id)
)
})
})
.collect::<Result<Vec<_>, _>>()?
}
};
let actual: BTreeSet<_> = current.iter().copied().collect();
let desired: BTreeSet<_> = supplied.iter().copied().collect();
if desired.len() != supplied.len() {
return Err(
"The final order contains a duplicate identity; reconcile its members explicitly."
.into(),
);
}
if desired != actual {
if decision == SemanticDecision::TakeSource
&& desired.is_subset(&actual)
&& current
.iter()
.filter(|id| desired.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
}
write(value, which, supplied)
}
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (a, b) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(a, b).is_some_and(|old| old != b) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
Ok(map)
}
pub(super) fn project_ordered_member(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
form_context: bool,
) -> Result<(), String> {
let FacetSelector::Member {
collection: member_collection,
keys,
} = facet
else {
unreachable!()
};
let which = ordered(*member_collection).expect("Ordered member");
let before = read(value, which)?;
self.keyed_value(project, trace, value, facet, decision)?;
let after = read(value, which)?;
let explicit = self
.decisions
.get(&FacetSelector::Order(which))
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
return Ok(());
}
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
(keys.oa, keys.sa)
} else {
(keys.sa, keys.oa)
};
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
return Ok(());
};
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("Source order is unavailable.")?,
)
.map_err(|e| e.to_string())?;
let position = source
.iter()
.position(|id| *id == source_key)
.ok_or("Source member is absent.")?;
let map = self.order_reference_map()?;
let anchor = |id: &Id| {
map.get(id)
.and_then(|mapped| before.iter().position(|id| id == mapped))
};
let left = source[..position].iter().rev().find_map(anchor);
let right = source[position + 1..].iter().find_map(anchor);
let at = match (left, right) {
(Some(a), Some(b)) if a + 1 == b => Some(b),
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
(None, Some(0)) => Some(0),
(None, None) if before.is_empty() => Some(0),
_ => None,
};
if let Some(at) = at {
let mut ordered = before;
ordered.insert(at, target_key);
write(value, which, ordered)
} else if form_context {
// An uncommitted membership list lets the user author a final order.
// The actual request still rejects ambiguous placement until explicitly decided.
Ok(())
} else {
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
}
}
}
//! Independent membership and complete final order; the core validates every proposal.
use super::*;
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
match which {
OrderedCollection::Steps => ReferenceCollection::Steps,
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
}
}
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
match which {
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
_ => None,
}
}
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
}
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
Ok(())
}
impl ReviewRow {
pub(super) fn ordered_members(
&self,
project: &Project,
which: OrderedCollection,
) -> Result<Vec<Id>, String> {
read(&self.combine(project, true, true)?, which)
}
pub(super) fn initialize_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
if !self.order_drafts.contains_key(&which) {
let ids = self.ordered_members(project, which)?;
self.order_drafts.insert(which, ids);
}
Ok(())
}
pub(super) fn reconcile_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let members = self.ordered_members(project, which)?;
let draft = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
// This is an explicit user action. Ordinary refresh never discards an authored order.
let mut seen = BTreeSet::new();
draft.retain(|id| members.contains(id) && seen.insert(*id));
for id in members {
if seen.insert(id) {
draft.push(id);
}
}
Ok(())
}
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
let facet = FacetSelector::Order(which);
if self.selected.as_ref() != Some(&facet)
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select Set final value for this order before changing it.".into());
}
Ok(())
}
pub(super) fn move_order(
&mut self,
which: OrderedCollection,
index: usize,
delta: i32,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let ids = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
let destination = match delta {
-1 => index.checked_sub(1),
1 => index.checked_add(1),
_ => None,
};
let destination = destination
.filter(|i| index < ids.len() && *i < ids.len())
.ok_or("Choose an existing adjacent position in the final order.")?;
ids.swap(index, destination);
Ok(())
}
pub(super) fn project_order(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
which: OrderedCollection,
decision: SemanticDecision,
) -> Result<(), String> {
let current = read(value, which)?;
let supplied = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::Candidate => self
.order_drafts
.get(&which)
.cloned()
.ok_or("Choose a final order.")?,
SemanticDecision::TakeSource => {
let fact =
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("The source has no applicable order.")?,
)
.map_err(|e| e.to_string())?;
let map = self.order_reference_map()?;
source
.into_iter()
.map(|id| {
map.get(&id).copied().ok_or_else(|| {
format!(
"Pair source order member {} explicitly in Reference pairs.",
project.named(id)
)
})
})
.collect::<Result<Vec<_>, _>>()?
}
};
let actual: BTreeSet<_> = current.iter().copied().collect();
let desired: BTreeSet<_> = supplied.iter().copied().collect();
if desired.len() != supplied.len() {
return Err(
"The final order contains a duplicate identity; reconcile its members explicitly."
.into(),
);
}
if desired != actual {
if decision == SemanticDecision::TakeSource
&& desired.is_subset(&actual)
&& current
.iter()
.filter(|id| desired.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
}
write(value, which, supplied)
}
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (a, b) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(a, b).is_some_and(|old| old != b) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
Ok(map)
}
pub(super) fn project_ordered_member(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
form_context: bool,
) -> Result<(), String> {
let FacetSelector::Member {
collection: member_collection,
keys,
} = facet
else {
unreachable!()
};
let which = ordered(*member_collection).expect("Ordered member");
let before = read(value, which)?;
self.keyed_value(project, trace, value, facet, decision)?;
let after = read(value, which)?;
let explicit = self
.decisions
.get(&FacetSelector::Order(which))
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
return Ok(());
}
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
(keys.oa, keys.sa)
} else {
(keys.sa, keys.oa)
};
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
return Ok(());
};
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("Source order is unavailable.")?,
)
.map_err(|e| e.to_string())?;
let position = source
.iter()
.position(|id| *id == source_key)
.ok_or("Source member is absent.")?;
let map = self.order_reference_map()?;
let anchor = |id: &Id| {
map.get(id)
.and_then(|mapped| before.iter().position(|id| id == mapped))
};
let left = source[..position].iter().rev().find_map(anchor);
let right = source[position + 1..].iter().find_map(anchor);
let at = match (left, right) {
(Some(a), Some(b)) if a + 1 == b => Some(b),
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
(None, Some(0)) => Some(0),
(None, None) if before.is_empty() => Some(0),
_ => None,
};
if let Some(at) = at {
let mut ordered = before;
ordered.insert(at, target_key);
write(value, which, ordered)
} else if form_context {
// An uncommitted membership list lets the user author a final order.
// The actual request still rejects ambiguous placement until explicitly decided.
Ok(())
} else {
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
}
}
}
-309
View File
@@ -1,309 +0,0 @@
//! Read-only source-range admission and projection onto the editor's real layout.
//! Byte ranges retain their diagnostic meaning; visual bands expand to clusters.
mod widget;
use iced::{Point, Rectangle, Size, Vector, advanced::graphics::text::cosmic_text};
use oa_core::source_project::SourceDiagnostic;
use std::{collections::BTreeMap, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
pub(crate) use widget::{Decorations, Snapshot};
#[derive(Debug)]
struct Part {
diagnostic: usize,
bytes: Range<usize>,
}
/// Source-only index, reusable across pointer movement, scroll and reflow.
/// A caller must separately establish publication/path/snapshot authority.
#[derive(Debug)]
pub(crate) struct Index {
source: Arc<str>,
lines: Vec<Range<usize>>,
parts: BTreeMap<usize, Vec<Part>>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Band {
pub diagnostic: usize,
pub bounds: Rectangle,
pub underline: Rectangle,
}
impl Index {
pub fn new(source: Arc<str>, path: &str, diagnostics: &[SourceDiagnostic]) -> Self {
// This is the same paragraph splitter used by the actual editor engine,
// including standalone CR and LFCR, not the diagnostic's LF-only label.
let mut lines: Vec<_> = cosmic_text::LineIter::new(&source)
.map(|(bytes, _)| bytes)
.collect();
if lines.last().is_none_or(|line| line.end < source.len()) {
lines.push(source.len()..source.len());
}
let mut parts: BTreeMap<usize, Vec<Part>> = BTreeMap::new();
for (ordinal, diagnostic) in diagnostics.iter().enumerate() {
let span = diagnostic.diagnostic.span;
if diagnostic.path != path
|| span.start > span.end
|| span.end > source.len()
|| !source.is_char_boundary(span.start)
|| !source.is_char_boundary(span.end)
{
continue;
}
let first = lines.partition_point(|line| line.start <= span.start) - 1;
let last_byte = if span.start == span.end {
span.end
} else {
span.end - 1
};
let last = lines.partition_point(|line| line.start <= last_byte) - 1;
for (line_index, line) in lines.iter().enumerate().take(last + 1).skip(first) {
let start = span.start.max(line.start).min(line.end) - line.start;
let end = span.end.min(line.end) - line.start;
parts.entry(line_index).or_default().push(Part {
diagnostic: ordinal,
bytes: start..end,
});
}
}
Self {
source,
lines,
parts,
}
}
/// Only actual visible runs are projected. The engine's iterator already
/// applies scroll; the caller supplies the actual padded origin and clip.
pub fn bands(&self, buffer: &cosmic_text::Buffer, origin: Point, clip: Rectangle) -> Vec<Band> {
let mut bands = Vec::new();
if self.parts.is_empty() || clip.width <= 0. || clip.height <= 0. {
return bands;
}
let translation = Vector::new(origin.x, origin.y);
let mut checked_line = None;
let mut source_matches = false;
for run in buffer.layout_runs() {
let Some(parts) = self.parts.get(&run.line_i) else {
continue;
};
let underline_y = origin.y + run.line_top + run.line_height - 2.;
if underline_y >= clip.y + clip.height || underline_y + 1.5 <= clip.y {
continue;
}
// Several visible wraps can share a very large paragraph. Validate
// its exact bytes once in this projection, never in a stale cache.
if checked_line != Some(run.line_i) {
checked_line = Some(run.line_i);
source_matches = self
.lines
.get(run.line_i)
.and_then(|line| self.source.get(line.clone()))
== Some(run.text);
}
if !source_matches {
// A mismatched editor must never remap a diagnostic by text search.
continue;
}
let (first_byte, last_byte) = run
.glyphs
.iter()
.fold((usize::MAX, 0), |(first, last), glyph| {
(first.min(glyph.start), last.max(glyph.end))
});
for part in parts {
// Bidirectional glyphs need not be ordered by source byte. Their
// envelope safely rejects disjoint ranges before a glyph scan.
// Empty ranges retain the separate logical gap/EOF rules.
if !part.bytes.is_empty()
&& (part.bytes.end <= first_byte || part.bytes.start >= last_byte)
{
continue;
}
let mut intervals: Vec<(f32, f32)> = if part.bytes.is_empty() {
anchor(buffer, &run, part.bytes.start)
.into_iter()
.map(|x| (x, x + 4.))
.collect()
} else {
run.glyphs
.iter()
.filter(|glyph| {
glyph.start < part.bytes.end && part.bytes.start < glyph.end
})
.map(|glyph| {
let left = glyph.x.min(glyph.x + glyph.w);
let right = glyph.x.max(glyph.x + glyph.w);
(left, right.max(left + 1.))
})
.collect()
};
intervals.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f32, f32)> = Vec::new();
for (left, right) in intervals {
if let Some(previous) = merged.last_mut()
&& left <= previous.1 + 0.01
{
previous.1 = previous.1.max(right);
} else {
merged.push((left, right));
}
}
for (mut left, mut right) in merged {
if part.bytes.is_empty() {
// An insertion at the right edge remains visible while
// still containing its true boundary, without borrowing a token.
let max_x = clip.x + clip.width - origin.x;
if left <= max_x && right > max_x {
right = max_x;
left = (right - 4.).max(0.);
}
}
let bounds = Rectangle::new(
Point::new(left, run.line_top),
Size::new(right - left, run.line_height),
) + translation;
let underline = Rectangle {
y: bounds.y + bounds.height - 2.,
height: 1.5,
..bounds
};
if let (Some(bounds), Some(underline)) =
(bounds.intersection(&clip), underline.intersection(&clip))
&& bounds.width > 0.
&& bounds.height > 0.
&& underline.width > 0.
&& underline.height > 0.
{
bands.push(Band {
diagnostic: part.diagnostic,
bounds,
underline,
});
}
}
}
}
bands
}
}
/// Prefer the following cluster at a wrap boundary. EOF uses the preceding
/// cluster's trailing edge; an empty paragraph has a real origin insertion.
fn anchor(
buffer: &cosmic_text::Buffer,
run: &cosmic_text::LayoutRun<'_>,
byte: usize,
) -> Option<f32> {
if run.glyphs.is_empty() {
return (byte == 0 && run.text.is_empty()).then_some(0.);
}
if let Some(glyph) = run
.glyphs
.iter()
.find(|glyph| glyph.start <= byte && byte < glyph.end)
{
// Match cosmic-text's cursor heuristic inside a shaped cluster: a
// proportional grapheme advance, including ligatures. This describes
// a caret affordance, not an exact font-provided ligature caret or ink.
let cluster = run.text.get(glyph.start..glyph.end)?;
let mut before = 0;
let mut total = 0;
for (offset, _) in cluster.grapheme_indices(true) {
before += usize::from(glyph.start + offset < byte);
total += 1;
}
let advance = glyph.w * before as f32 / total.max(1) as f32;
return Some(if glyph.level.is_rtl() {
glyph.x + glyph.w - advance
} else {
glyph.x + advance
});
}
// Visual order need not be logical order, even between wrapped runs. First
// find this run's best logical predecessor, not its largest end overall.
let previous = run
.glyphs
.iter()
.filter(|glyph| glyph.end <= byte)
.max_by_key(|glyph| (glyph.end, glyph.start))?;
// Most unrelated runs fail here without scanning the paragraph's layout.
// Only a genuinely omitted separator or EOF may use a predecessor edge.
if !run
.text
.get(previous.end..byte)?
.chars()
.all(char::is_whitespace)
|| (byte != run.text.len() && !run.text.get(byte..)?.chars().next()?.is_whitespace())
{
return None;
}
let layout = buffer.lines.get(run.line_i)?.layout_opt()?;
let key = (previous.end, previous.start);
let mut current_found = false;
for line in layout {
let current = std::ptr::eq(line.glyphs.as_slice(), run.glyphs);
current_found |= current;
for glyph in &line.glyphs {
// A painted source position, including an exact next-run start,
// belongs to that containing run, never to a gap fallback here.
if glyph.start <= byte && byte < glyph.end {
return None;
}
let candidate = (glyph.end, glyph.start);
if glyph.end <= byte && candidate > key {
return None;
}
// A cluster split across visual runs has no single run-local edge.
// Suppress unavailable geometry instead of moving it to a neighbor.
if !current && candidate == key {
return None;
}
}
}
if !current_found {
return None;
}
cluster_trailing_edge(run.glyphs, previous)
}
fn cluster_trailing_edge(
glyphs: &[cosmic_text::LayoutGlyph],
cluster: &cosmic_text::LayoutGlyph,
) -> Option<f32> {
// Multiple glyphs can share a source cluster. Their iterator order follows
// paragraph layout, so its last entry need not be the logical trailing edge.
let rtl = cluster.level.is_rtl();
let mut edge = None;
for glyph in glyphs
.iter()
.filter(|glyph| glyph.start == cluster.start && glyph.end == cluster.end)
{
if glyph.level.is_rtl() != rtl {
return None;
}
let next = if rtl {
glyph.x.min(glyph.x + glyph.w)
} else {
glyph.x.max(glyph.x + glyph.w)
};
edge = Some(edge.map_or(next, |value: f32| {
if rtl {
value.min(next)
} else {
value.max(next)
}
}));
}
edge
}
#[cfg(test)]
mod layout_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod anchor_tests;
#[cfg(test)]
mod performance_tests;
@@ -1,260 +0,0 @@
//! Manual actual-font witnesses; missing supported glyphs fail fixture admission.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
fn shaped(source: &str, width: f32) -> Editor {
let mut editor = Editor::with_text(source);
let mut highlighter = PlainText::new(&());
editor.update(
Size::new(width, 1200.),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut highlighter,
);
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
editor
}
fn trailing(cluster: &[&cosmic_text::LayoutGlyph]) -> f32 {
let rtl = cluster[0].level.is_rtl();
assert!(cluster.iter().all(|g| g.level.is_rtl() == rtl));
if rtl {
cluster
.iter()
.map(|g| g.x.min(g.x + g.w))
.fold(f32::INFINITY, f32::min)
} else {
cluster
.iter()
.map(|g| g.x.max(g.x + g.w))
.fold(f32::NEG_INFINITY, f32::max)
}
}
#[test]
#[ignore = "manual supported-font cluster and bidi witnesses"]
fn final_multi_glyph_cluster_anchor_uses_logical_trailing_advance_edge() {
// Marks without precomposed forms exercise real multi-glyph shaping. Test
// both paragraph directions and both final-cluster directions. No font is
// assumed to support a given specimen; glyph_id=0 cannot admit a witness.
let sources = [
"abc q\u{301}\u{323}",
"אבג q\u{301}\u{323}",
"abc א\u{5b0}\u{5bc}",
"אבג א\u{5b0}\u{5bc}",
"abc ب\u{64e}\u{651}",
"אבג ب\u{64e}\u{651}",
"abc x\u{20dd}",
"אבג x\u{20dd}",
];
let mut admitted = 0;
let mut opposite_direction = 0;
for source in sources {
let editor = shaped(source, 700.);
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert_eq!(runs.len(), 1, "wide specimen unexpectedly wrapped");
let run = &runs[0];
let Some(start) = run
.glyphs
.iter()
.filter(|g| g.end == source.len())
.map(|g| g.start)
.max()
else {
continue;
};
let cluster: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start == start && g.end == source.len())
.collect();
if cluster.len() < 2 || cluster.iter().any(|g| g.glyph_id == 0) {
continue;
}
admitted += 1;
opposite_direction += usize::from(cluster[0].level.is_rtl() != run.rtl);
let expected = trailing(&cluster);
let actual = anchor(editor.buffer(), run, source.len());
eprintln!(
"EOF witness {source:?}: paragraph_rtl={} cluster={cluster:?} expected={expected} actual={actual:?}",
run.rtl
);
assert!(
actual.is_some_and(|x| (x - expected).abs() < 0.01),
"logical EOF must follow the whole cluster, including shared-range glyphs; source={source:?}"
);
}
assert!(
admitted > 0,
"fixture admission: current fonts produced no supported multi-glyph final cluster"
);
assert!(
opposite_direction > 0,
"fixture admission: no supported final cluster opposed its paragraph direction"
);
}
#[test]
#[ignore = "manual supported-font cluster and bidi witnesses"]
fn mixed_direction_omitted_separator_has_one_logical_predecessor_anchor() {
let sources = [
"one אבג דהו two זחט three יכל four מנס five עפצ six קרש seven",
"אבג one two דהו three four זחט five six יכל seven eight מנס nine",
];
let mut admitted = 0;
let mut rtl_predecessors = 0;
let mut failures = Vec::new();
let mut old_mismatches = 0;
let mut old_inside_mismatches = 0;
for source in sources {
for width in [72., 96., 120., 144., 180.] {
let editor = shaped(source, width);
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert!(runs.len() > 1);
assert_eq!(
runs.len(),
editor.buffer().lines[0].layout_opt().unwrap().len(),
"negative control requires the complete unscrolled paragraph"
);
assert!(
runs.iter()
.any(|r| r.glyphs.iter().any(|g| g.level.is_rtl()))
);
assert!(
runs.iter()
.any(|r| r.glyphs.iter().any(|g| !g.level.is_rtl()))
);
for (byte, character) in source.char_indices().filter(|(_, c)| *c == ' ') {
// Require a genuinely omitted separator, not a rendered space.
if runs
.iter()
.any(|r| r.glyphs.iter().any(|g| g.start <= byte && byte < g.end))
{
continue;
}
let Some(end) = runs
.iter()
.flat_map(|r| r.glyphs)
.filter(|g| g.end <= byte)
.map(|g| g.end)
.max()
else {
continue;
};
let Some(next) = runs
.iter()
.flat_map(|r| r.glyphs)
.filter(|g| g.start >= byte + character.len_utf8())
.map(|g| g.start)
.min()
else {
continue;
};
if !source[end..next].chars().all(char::is_whitespace) {
continue;
}
let previous: Vec<_> = runs
.iter()
.enumerate()
.filter(|(_, r)| r.glyphs.iter().any(|g| g.end == end))
.collect();
// A cluster broken across runs is a separate boundary problem;
// it is not admitted as this omitted-separator witness.
if previous.len() != 1 {
continue;
}
let (owner, run) = previous[0];
let start = run
.glyphs
.iter()
.filter(|g| g.end == end)
.map(|g| g.start)
.max()
.unwrap();
let cluster: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start == start && g.end == end)
.collect();
if cluster.iter().any(|g| g.glyph_id == 0) {
continue;
}
admitted += 1;
rtl_predecessors += usize::from(cluster[0].level.is_rtl());
let expected = trailing(&cluster);
let actual: Vec<_> = runs
.iter()
.enumerate()
.filter_map(|(i, r)| anchor(editor.buffer(), r, byte).map(|x| (i, x)))
.collect();
let matches_expected = |positions: &[(usize, f32)]| {
positions.len() == 1
&& positions[0].0 == owner
&& (positions[0].1 - expected).abs() < 0.01
};
// Exact rejected fallback, for already-proven omitted bytes:
// per-run maximum end and the next visual run's minimum start.
let old: Vec<_> = runs
.iter()
.enumerate()
.filter_map(|(i, r)| {
let last = r.glyphs.iter().max_by_key(|g| g.end)?;
let next_start = runs
.get(i + 1)
.and_then(|next| next.glyphs.iter().map(|g| g.start).min());
(byte >= last.end
&& (next_start.is_some_and(|start| byte < start)
|| (next_start.is_none() && byte == source.len())))
.then_some((
i,
if last.level.is_rtl() {
last.x
} else {
last.x + last.w
},
))
})
.collect();
let inside_clip = expected >= 0. && expected <= width;
if !matches_expected(&old) {
old_mismatches += 1;
old_inside_mismatches += usize::from(inside_clip);
}
eprintln!(
"gap witness width={width} source={source:?} byte={byte} owner={owner} expected={expected} inside_horizontal_clip={inside_clip} actual={actual:?} rejected_old={old:?} cluster={cluster:?}"
);
if !matches_expected(&actual) {
failures.push(format!("width={width} source={source:?} byte={byte} expected=({owner},{expected}) inside={inside_clip} actual={actual:?}"));
}
}
}
}
assert!(
admitted > 0,
"fixture admission: no omitted mixed-direction separator found"
);
assert!(
rtl_predecessors > 0,
"fixture admission: no omitted separator after a supported RTL cluster"
);
eprintln!(
"gap summary admitted={admitted} rejected_old_mismatches={old_mismatches} rejected_old_inside_horizontal_clip={old_inside_mismatches}"
);
assert!(
old_mismatches > 0,
"actual-layout negative control must discriminate the old fallback"
);
assert!(
failures.is_empty(),
"mixed-bidi logical anchor failures: {failures:#?}"
);
}
@@ -1,321 +0,0 @@
//! Real shaped-layout projection checks. No window/GPU or hover authority claim.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
const ORIGIN: Point = Point::new(37., 53.);
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: "a".into(),
diagnostic: Diagnostic {
severity: Severity::Error,
code: "layout".into(),
message: "fixture".into(),
object_id: None,
field: None,
line: 999,
column: 999,
span: Span { start, end },
},
related: Vec::new(),
}
}
fn editor(source: &str, width: f32, height: f32) -> Editor {
let mut editor = Editor::with_text(source);
let mut highlighter = PlainText::new(&());
editor.update(
Size::new(width, height),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut highlighter,
);
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
editor
}
fn index(source: &str, ranges: &[Range<usize>]) -> Index {
Index::new(
Arc::from(source),
"a",
&ranges
.iter()
.map(|r| diagnostic(r.start, r.end))
.collect::<Vec<_>>(),
)
}
fn clip(width: f32, height: f32) -> Rectangle {
Rectangle::new(ORIGIN, Size::new(width, height))
}
fn covered(bands: &[Band], point: Point, ordinal: usize) -> bool {
bands
.iter()
.any(|b| b.diagnostic == ordinal && b.bounds.contains(point))
}
fn center(run: &cosmic_text::LayoutRun<'_>, glyph: &cosmic_text::LayoutGlyph) -> Point {
Point::new(
ORIGIN.x + glyph.x + glyph.w / 2.,
ORIGIN.y + run.line_top + run.line_height / 2.,
)
}
#[test]
fn repeated_alias_marks_only_second_actual_glyph_sequence_and_blank_space_misses() {
let source = "alias alias";
let editor = editor(source, 500., 120.);
let bands = index(source, std::slice::from_ref(&(6..11))).bands(
editor.buffer(),
ORIGIN,
clip(500., 120.),
);
let run = editor.buffer().layout_runs().next().unwrap();
let mut selected = 0;
let mut rejected = 0;
for glyph in run.glyphs {
let at = center(&run, glyph);
if glyph.start >= 6 {
assert!(covered(&bands, at, 0));
selected += 1;
}
if glyph.end <= 5 {
assert!(!covered(&bands, at, 0));
rejected += 1;
}
}
assert!(selected > 0 && rejected > 0);
assert!(!covered(
&bands,
Point::new(ORIGIN.x + 450., ORIGIN.y + 12.),
0
));
assert!(!covered(
&bands,
Point::new(ORIGIN.x + 12., ORIGIN.y + 100.),
0
));
// Wrong-origin negative control: expected source glyphs are not at raw local positions.
assert!(!covered(&bands, Point::new(0., 0.), 0));
}
#[test]
fn wrapped_scrolled_multiline_range_uses_actual_visible_runs_and_effective_clip() {
let source = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\nsecond source line has more words\nthird source line\n";
let mut editor = editor(source, 150., 110.);
assert!(
editor
.buffer()
.layout_runs()
.filter(|r| r.line_i == 0)
.count()
>= 2
);
editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2 });
let scroll = editor.buffer().scroll();
assert!(scroll.line > 0 || scroll.vertical > 0.);
let effective = Rectangle::new(
Point::new(ORIGIN.x + 9., ORIGIN.y + 5.),
Size::new(126., 91.),
);
let bands = index(source, std::slice::from_ref(&(0..source.len()))).bands(
editor.buffer(),
ORIGIN,
effective,
);
assert!(!bands.is_empty());
for b in &bands {
assert_eq!(b.bounds.intersection(&effective), Some(b.bounds));
assert_eq!(b.underline.intersection(&effective), Some(b.underline));
}
let mut checked = 0;
for run in editor.buffer().layout_runs() {
// Only rows whose actual underline is inside the clip are hoverable by policy.
let underline_y = ORIGIN.y + run.line_top + run.line_height - 1.;
if underline_y < effective.y || underline_y >= effective.y + effective.height {
continue;
}
for glyph in run.glyphs {
let at = center(&run, glyph);
if effective.contains(at) {
assert!(covered(&bands, at, 0), "visible glyph {glyph:?}");
checked += 1;
}
}
}
assert!(checked > 0);
assert_eq!(editor.buffer().scroll(), scroll);
assert!(
index(source, std::slice::from_ref(&(0..source.len())))
.bands(
editor.buffer(),
ORIGIN,
Rectangle::new(Point::new(-500., -500.), Size::new(10., 10.))
)
.is_empty()
);
}
#[test]
fn combining_cjk_and_bidi_ranges_cover_actual_clusters_without_neighbor_aliases() {
let source = "left e\u{301} 救援 abc אבג xyz right";
let editor = editor(source, 700., 120.);
let combining = source.find("e\u{301}").unwrap();
let cjk = source.find('救').unwrap();
let rtl = source.find('א').unwrap();
let ranges = [
combining + 1..combining + 3,
cjk..cjk + "救援".len(),
rtl..rtl + "אבג".len(),
];
let bands = index(source, &ranges).bands(editor.buffer(), ORIGIN, clip(700., 120.));
let run = editor.buffer().layout_runs().next().unwrap();
assert!(
run.glyphs.iter().any(|g| g.level.is_rtl()),
"fixture must shape RTL"
);
assert!(
run.glyphs
.iter()
.any(|g| g.start <= combining && g.end >= combining + 3),
"fixture must shape combining cluster"
);
for (ordinal, range) in ranges.iter().enumerate() {
let targets: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start < range.end && range.start < g.end)
.collect();
assert!(!targets.is_empty());
for g in targets {
assert!(covered(&bands, center(&run, g), ordinal));
}
}
for g in run.glyphs.iter().filter(|g| {
g.end <= 4
|| g.start >= source.find("right").unwrap()
|| (g.start >= source.find("abc").unwrap() && g.end <= source.find("abc").unwrap() + 3)
|| (g.start >= source.find("xyz").unwrap() && g.end <= source.find("xyz").unwrap() + 3)
}) {
assert!(!(0..ranges.len()).any(|n| covered(&bands, center(&run, g), n)));
}
}
#[test]
fn omitted_wrap_space_has_one_preceding_run_anchor_and_none_when_that_run_is_clipped_out() {
let source = "one two three four five six seven eight nine ten";
let mut found = None;
for width in [60., 72., 84., 96., 108., 120., 132., 144.] {
let e = editor(source, width, 600.);
let gap = {
let runs: Vec<_> = e.buffer().layout_runs().collect();
runs.windows(2).find_map(|pair| {
if pair[0].line_i != pair[1].line_i {
return None;
}
let before = pair[0].glyphs.iter().max_by_key(|g| g.end)?;
let after = pair[1].glyphs.iter().map(|g| g.start).min()?;
(before.end < after && source[before.end..after].chars().all(char::is_whitespace))
.then_some((
before.end,
before.x + before.w,
pair[0].line_top,
pair[0].line_height,
pair[1].line_top,
))
})
};
if let Some(gap) = gap {
found = Some((e, width, gap));
break;
}
}
let (editor, width, (byte, edge, top, height, next_top)) =
found.expect("actual wrapping must omit a separator; do not silently skip fixture");
let idx = index(source, std::slice::from_ref(&(byte..byte)));
let bands = idx.bands(editor.buffer(), ORIGIN, clip(width, 600.));
assert_eq!(
bands.len(),
1,
"exactly the preceding actual run owns the gap"
);
assert!((bands[0].bounds.y - (ORIGIN.y + top)).abs() < 0.01);
assert!(
bands[0].bounds.x <= ORIGIN.x + edge
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
);
assert!((bands[0].bounds.height - height).abs() < 0.01);
let later_only = Rectangle::new(
Point::new(ORIGIN.x, ORIGIN.y + next_top),
Size::new(width, 100.),
);
assert!(
idx.bands(editor.buffer(), ORIGIN, later_only).is_empty(),
"must not relocate hidden prior-run anchor into next visible run"
);
}
#[test]
fn empty_eof_and_separator_ranges_follow_real_paragraph_ownership() {
for source in ["", "a", "a\n"] {
let editor = editor(source, 200., 200.);
let byte = source.len();
let bands = index(source, std::slice::from_ref(&(byte..byte))).bands(
editor.buffer(),
ORIGIN,
clip(200., 200.),
);
assert_eq!(bands.len(), 1, "EOF {source:?}");
let run = editor.buffer().layout_runs().last().unwrap();
assert!((bands[0].bounds.y - (ORIGIN.y + run.line_top)).abs() < 0.01);
let edge = run.glyphs.last().map_or(0., |g| g.x + g.w);
assert!(
bands[0].bounds.x <= ORIGIN.x + edge
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
);
}
let source = "a\r\n\r\n雨\nz";
let editor = editor(source, 200., 200.);
let bands =
index(source, &[0..source.len(), 1..3]).bands(editor.buffer(), ORIGIN, clip(200., 200.));
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert_eq!(runs.len(), 4);
for run in &runs {
let x = run.glyphs.first().map_or(2., |g| g.x + g.w / 2.);
assert!(covered(
&bands,
Point::new(ORIGIN.x + x, ORIGIN.y + run.line_top + run.line_height / 2.),
0
));
}
let separator: Vec<_> = bands.iter().filter(|b| b.diagnostic == 1).collect();
assert_eq!(separator.len(), 1);
assert_eq!(separator[0].bounds.y, ORIGIN.y + runs[0].line_top);
}
#[test]
fn mismatched_affected_source_and_bottom_line_without_visible_underline_have_no_bands() {
let actual = editor("other", 300., 100.);
assert!(
index("alias", std::slice::from_ref(&(0..5)))
.bands(actual.buffer(), ORIGIN, clip(300., 100.))
.is_empty()
);
let actual = editor("alias", 300., 100.);
let run = actual.buffer().layout_runs().next().unwrap();
let top_only = Rectangle::new(ORIGIN, Size::new(300., run.line_height / 2.));
assert!(
index("alias", std::slice::from_ref(&(0..5)))
.bands(actual.buffer(), ORIGIN, top_only)
.is_empty(),
"partially visible text with clipped underline is not hoverable by declared policy"
);
}
@@ -1,216 +0,0 @@
//! Manual actual-layout timing distributions; no fixed machine threshold.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
use std::{hint::black_box, time::Instant};
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: "a".into(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "perf".into(),
message: "prepared geometry specimen".into(),
object_id: None,
field: None,
line: 0,
column: 0,
span: Span { start, end },
},
related: Vec::new(),
}
}
fn shaped(source: &str) -> Editor {
let mut e = Editor::with_text(source);
settle_layout(&mut e);
e
}
// Stock TextEditor calls Editor::update during the next layout after input.
// Scroll initially changes pixel offset only; update normalizes logical scroll
// and shapes newly visible paragraphs before draw/highlight consumes them.
fn settle_layout(e: &mut Editor) {
let mut h = PlainText::new(&());
e.update(
Size::new(640., 240.),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut h,
);
e.highlight(Font::MONOSPACE, &mut h, |_| Format::default());
}
fn distribution<T>(label: &str, batch: usize, mut operation: impl FnMut() -> T) {
drop(black_box(operation())); // one excluded warmup
let mut samples = Vec::new();
for _ in 0..5 {
let mut elapsed = 0;
for _ in 0..batch {
let start = Instant::now();
let value = operation();
black_box(&value);
elapsed += start.elapsed().as_nanos();
drop(value); // allocation/destruction separated: operation includes allocation, excludes drop
}
samples.push(elapsed / batch as u128);
}
let mut sorted = samples.clone();
sorted.sort_unstable();
println!(
"TIMING {label} batch={batch} warmup=1 measured=5 ns_per_call={samples:?} min={} median={} max={} drop_excluded=true",
sorted[0], sorted[2], sorted[4]
);
}
fn run_view(label: &str, source: Arc<str>, editor: &Editor, dense: &[SourceDiagnostic]) {
let buffer = editor.buffer();
let runs: Vec<_> = buffer.layout_runs().collect();
assert!(!runs.is_empty());
let visible_glyphs: usize = runs.iter().map(|r| r.glyphs.len()).sum();
let first = runs
.iter()
.find_map(|r| {
r.glyphs
.iter()
.find(|g| g.w > 0.)
.map(|g| (r.line_i, g.start, g.end))
})
.unwrap();
let line_starts: Vec<_> = cosmic_text::LineIter::new(&source)
.map(|(r, _)| r.start)
.collect();
let sparse = [diagnostic(
line_starts[first.0] + first.1,
line_starts[first.0] + first.2,
)];
let sparse_index = Index::new(source.clone(), "a", &sparse);
let dense_index = Index::new(source.clone(), "a", dense);
let empty_index = Index::new(source.clone(), "a", &[]);
let origin = Point::new(17., 29.);
let clip = Rectangle::new(origin, Size::new(640., 240.));
let sparse_bands = sparse_index.bands(buffer, origin, clip);
assert!(!sparse_bands.is_empty(), "positive visible glyph fixture");
assert!(empty_index.bands(buffer, origin, clip).is_empty());
let rejected = Rectangle::new(Point::new(-1000., -1000.), Size::new(1., 1.));
assert!(
dense_index.bands(buffer, origin, rejected).is_empty(),
"offscreen-output negative control"
);
let dense_count = dense_index.bands(buffer, origin, clip).len();
println!(
"CASE {label} bytes={} paragraphs={} diagnostics={} visible_runs={} visible_glyphs={visible_glyphs} dense_bands={dense_count} scroll={:?}",
source.len(),
buffer.lines.len(),
dense.len(),
runs.len(),
buffer.scroll()
);
distribution(&format!("{label}/index-build-dense"), 1, || {
Index::new(black_box(source.clone()), "a", black_box(dense))
});
distribution(&format!("{label}/layout-runs-only"), 8, || {
black_box(buffer)
.layout_runs()
.map(|r| r.glyphs.len())
.sum::<usize>()
});
distribution(&format!("{label}/bands-no-diagnostics"), 8, || {
empty_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-one-visible"), 8, || {
sparse_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-dense"), 8, || {
dense_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-dense-rejected-clip"), 8, || {
dense_index.bands(black_box(buffer), origin, rejected)
});
}
#[test]
#[ignore = "manual diagnostic projection distributions; no fixed machine threshold"]
fn annotation_projection_cost_distributions() {
// Ordinary file: many logical paragraphs; deep scroll can skip by paragraph.
let line = "alpha beta gamma delta epsilon\n";
let ordinary: Arc<str> = Arc::from(line.repeat(4000));
let ordinary_diagnostics: Vec<_> = (0..4000)
.map(|i| diagnostic(i * line.len(), i * line.len() + 5))
.collect();
let mut ordinary_editor = shaped(&ordinary);
run_view(
"ordinary/top",
ordinary.clone(),
&ordinary_editor,
&ordinary_diagnostics,
);
ordinary_editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2000 });
settle_layout(&mut ordinary_editor);
assert!(
ordinary_editor.buffer().scroll().line > 1000,
"must really reach a deep logical paragraph"
);
assert!(
ordinary_editor
.buffer()
.layout_runs()
.next()
.unwrap()
.line_i
> 1000,
"newly visible deep paragraph must actually be shaped"
);
run_view(
"ordinary/deep",
ordinary,
&ordinary_editor,
&ordinary_diagnostics,
);
// Single paragraph: diagnostics near beginning remain indexed on the same
// logical line even when the visible runs are far below all of them.
let token = "alpha beta gamma delta ";
let single: Arc<str> = Arc::from(token.repeat(6000));
let dense: Vec<_> = (0..1500)
.map(|i| diagnostic(i * token.len(), i * token.len() + 5))
.collect();
let mut single_editor = shaped(&single);
let wrap_count = single_editor.buffer().lines[0].layout_opt().unwrap().len();
assert!(wrap_count > 1000, "fixture must shape thousands of wraps");
run_view("single/top", single.clone(), &single_editor, &dense);
single_editor.perform(iced::widget::text_editor::Action::Scroll {
lines: (wrap_count / 2) as i32,
});
settle_layout(&mut single_editor);
let buffer = single_editor.buffer();
assert_eq!(buffer.scroll().line, 0);
assert!(
buffer.scroll().vertical > 1000.,
"must really scroll within same paragraph"
);
let first_visible = buffer.layout_runs().next().unwrap();
assert!(
first_visible.glyphs.iter().map(|g| g.start).min().unwrap()
> dense.last().unwrap().diagnostic.span.end,
"dense diagnostics must all be above the deep viewport"
);
run_view(
"single/deep-offscreen-diagnostics",
single,
&single_editor,
&dense,
);
println!(
"DONE manual cost distributions; compare controls and top/deep ratios, not a platform-independent latency PASS"
);
}
@@ -1,83 +0,0 @@
use super::*;
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
fn diagnostic(path: &str, start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: path.into(),
diagnostic: Diagnostic {
severity: Severity::Error,
code: "test".into(),
message: "synthetic admission fixture".into(),
object_id: None,
field: None,
line: 999,
column: 999,
span: Span { start, end },
},
related: Vec::new(),
}
}
#[test]
fn exact_path_and_utf8_range_admission_keep_valid_neighbor() {
let index = Index::new(
Arc::from("雨 x"),
"a.arcadia",
&[
diagnostic("b.arcadia", 0, 3),
diagnostic("a.arcadia", 1, 3),
diagnostic("a.arcadia", 4, 2),
diagnostic("a.arcadia", 0, 99),
diagnostic("a.arcadia", 4, 5),
],
);
assert_eq!(index.parts.len(), 1);
let parts = &index.parts[&0];
assert_eq!(parts.len(), 1);
assert_eq!((parts[0].diagnostic, parts[0].bytes.clone()), (4, 4..5));
}
#[test]
fn mixed_endings_use_actual_paragraphs_and_preserve_eof() {
let index = Index::new(
Arc::from("a\r\n雨\rb\n\rc\n"),
"a",
&[
diagnostic("a", 3, 6),
diagnostic("a", 7, 8),
diagnostic("a", 12, 12),
],
);
assert_eq!(index.lines, vec![0..1, 3..6, 7..8, 10..11, 12..12]);
assert_eq!(index.parts[&1][0].bytes, 0..3);
assert_eq!(index.parts[&2][0].bytes, 0..1);
assert_eq!(index.parts[&4][0].bytes, 0..0);
}
#[test]
fn half_open_next_line_excluded_separator_only_and_empty_line_anchored() {
let index = Index::new(
Arc::from("ab\r\n\r\ncd"),
"a",
&[
diagnostic("a", 0, 4),
diagnostic("a", 2, 4),
diagnostic("a", 4, 6),
],
);
assert_eq!(index.parts.len(), 2);
assert_eq!(index.parts[&0][0].bytes, 0..2);
assert_eq!(index.parts[&0][1].bytes, 2..2);
assert_eq!(index.parts[&1][0].bytes, 0..0);
assert!(!index.parts.contains_key(&2));
}
#[test]
fn empty_source_has_real_zero_width_marker() {
let index = Index::new(Arc::from(""), "a", &[diagnostic("a", 0, 0)]);
assert_eq!(index.lines, vec![0..0]);
assert_eq!(index.parts[&0][0].bytes, 0..0);
}
@@ -1,425 +0,0 @@
//! Permanent passive decoration around the existing editor. Draw uses current
//! geometry; only the source index is cached, never pointer hitboxes or messages.
//! Current-frame tooltip paragraphs stay owned until deferred renderer preparation.
use super::Index;
use iced::advanced::text::Paragraph as _;
use iced::{
Element, Event, Font, Length, Padding, Point, Rectangle, Size, Theme, Vector,
advanced::{
Clipboard, Layout, Shell, Widget, graphics, layout, overlay, renderer, text, widget,
},
alignment, mouse,
widget::text_editor,
};
use oa_core::{Id, source_project::SourceDiagnostic, validation::Severity};
use std::{cell::RefCell, sync::Arc};
pub(crate) struct Decorations<'a, Message, R: text::Renderer> {
child: Element<'a, Message, Theme, R>,
content: &'a text_editor::Content<R>,
snapshot: Option<Snapshot<'a>>,
padding: Padding,
hover_enabled: bool,
}
/// Supplied only after the App admits a current diagnostic publication for the
/// displayed draft. Absence keeps the editor mounted with no annotations.
pub(crate) struct Snapshot<'a> {
pub publication: Id,
pub source: Arc<str>,
pub path: &'a str,
pub diagnostics: &'a [SourceDiagnostic],
}
impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> {
pub fn new(
child: impl Into<Element<'a, Message, Theme, R>>,
content: &'a text_editor::Content<R>,
snapshot: Option<Snapshot<'a>>,
padding: Padding,
hover_enabled: bool,
) -> Self {
Self {
child: child.into(),
content,
snapshot,
padding,
hover_enabled,
}
}
}
struct Cached {
publication: Id,
path: String,
source: Arc<str>,
index: Index,
}
#[derive(Default)]
struct State {
index: RefCell<Option<Cached>>,
details: RefCell<Option<DetailsParagraphs>>,
}
// WGPU queues weak paragraph references during draw and upgrades them later.
// This is render-resource ownership, not a cache authorizing future hover hits.
struct DetailsParagraphs {
_body: graphics::text::Paragraph,
_footer: graphics::text::Paragraph,
}
impl<Message, R> Widget<Message, Theme, R> for Decorations<'_, Message, R>
where
R: text::Renderer<
Font = Font,
Editor = graphics::text::Editor,
Paragraph = graphics::text::Paragraph,
>,
{
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.child)]
}
fn diff(&self, tree: &mut widget::Tree) {
// A publication change only replaces the read-only index. The editor's
// existing Tree and hidden selection/IME/scroll state remain mounted.
tree.diff_children(std::slice::from_ref(&self.child));
}
fn size(&self) -> Size<Length> {
self.child.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.child.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.child
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.child
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
) {
// Movement within a focused editor need not change stock status.
// Request painting, but do not capture or publish any input action.
shell.request_redraw();
}
self.child.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.child.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
let state = tree.state.downcast_ref::<State>();
// Revoke the preceding frame's text even on every early/hidden path.
// Current source admission and current geometry alone decide new text.
let mut details = state.details.borrow_mut();
*details = None;
let mut cache = state.index.borrow_mut();
let Some(snapshot) = &self.snapshot else {
*cache = None;
return;
};
let inner = layout.bounds().shrink(self.padding);
let Some(clip) = inner
.intersection(viewport)
.filter(|clip| clip.width > 0. && clip.height > 0.)
else {
return;
};
if cache.as_ref().is_none_or(|cached| {
cached.publication != snapshot.publication
|| cached.path != snapshot.path
|| !Arc::ptr_eq(&cached.source, &snapshot.source)
}) {
*cache = Some(Cached {
publication: snapshot.publication,
path: snapshot.path.into(),
source: snapshot.source.clone(),
index: Index::new(snapshot.source.clone(), snapshot.path, snapshot.diagnostics),
});
}
// Child draw has now applied the actual syntax highlighter. Release the
// Content borrow before further renderer work; no callback reentrancy.
let mut bands = self.content.with_editor(|editor| {
cache
.as_ref()
.unwrap()
.index
.bands(editor.buffer(), inner.position(), clip)
});
bands.sort_by_key(
|band| match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
Severity::Warning => 0,
Severity::Error => 1,
},
);
let palette = theme.palette();
renderer.start_layer(clip);
for band in &bands {
let color = match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
Severity::Warning => palette.warning,
Severity::Error => palette.danger,
};
renderer.fill_quad(
renderer::Quad {
bounds: band.underline,
..Default::default()
},
color,
);
}
if self.hover_enabled
&& let Some(point) = cursor.position_over(clip)
{
let mut hits: Vec<_> = bands
.iter()
.filter(|band| band.bounds.contains(point))
.map(|band| band.diagnostic)
.collect();
hits.sort_unstable();
hits.dedup();
hits.sort_by_key(|ordinal| {
(
match snapshot.diagnostics[*ordinal].diagnostic.severity {
Severity::Error => 0,
Severity::Warning => 1,
},
*ordinal,
)
});
if !hits.is_empty() {
*details = draw_details(renderer, theme, snapshot.diagnostics, &hits, point, clip);
}
}
renderer.end_layer();
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.child.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.child.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R> From<Decorations<'a, Message, R>> for Element<'a, Message, Theme, R>
where
R: text::Renderer<
Font = Font,
Editor = graphics::text::Editor,
Paragraph = graphics::text::Paragraph,
> + 'a,
{
fn from(value: Decorations<'a, Message, R>) -> Self {
Element::new(value)
}
}
fn draw_details<R: text::Renderer<Font = Font, Paragraph = graphics::text::Paragraph>>(
renderer: &mut R,
theme: &Theme,
diagnostics: &[SourceDiagnostic],
hits: &[usize],
point: Point,
clip: Rectangle,
) -> Option<DetailsParagraphs> {
const PAD: f32 = 8.;
if clip.width < 100. || clip.height < 80. {
return None;
}
let width = 520_f32.min(clip.width);
let mut details = Vec::new();
for ordinal in hits.iter().take(4) {
let issue = &diagnostics[*ordinal];
let d = &issue.diagnostic;
let severity = match d.severity {
Severity::Error => "Error",
Severity::Warning => "Warning",
};
let raw = format!(
"{severity} [{}] · {}:{}\n{}",
d.code, d.line, d.column, d.message
);
let mut value: String = raw.chars().take(1000).collect();
if value.len() < raw.len() {
value.push('…');
}
if !issue.related.is_empty() {
value.push_str(&format!(
"\n{} related location(s) in Problems",
issue.related.len()
));
}
details.push(value);
}
let content = details.join("\n\n");
let paragraph = graphics::text::Paragraph::with_text(text::Text {
content: content.as_str(),
bounds: Size::new(width - PAD * 2., f32::INFINITY),
size: 12.into(),
line_height: text::LineHeight::Relative(1.3),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
});
let remaining = hits.len().saturating_sub(4);
let footer = if remaining > 0 {
format!(
"4 of {} shown · {remaining} more\nFull text: Actions → Source problems",
hits.len()
)
} else {
"Full text and locations: Actions → Source problems".into()
};
let footer_paragraph = graphics::text::Paragraph::with_text(text::Text {
content: footer.as_str(),
bounds: Size::new(width - PAD * 2., f32::INFINITY),
size: 11.into(),
line_height: text::LineHeight::Relative(1.2),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
});
let footer_height = footer_paragraph.min_bounds().height;
let available_body = clip.height - PAD * 2. - footer_height - 4.;
if available_body < 16. {
return None;
}
let max_body_height =
(clip.height * 0.6 - PAD * 2. - footer_height - 4.).clamp(16., available_body);
let body_height = paragraph.min_bounds().height.min(max_body_height);
let height = PAD * 2. + body_height + 4. + footer_height;
let x = point.x.min(clip.x + clip.width - width).max(clip.x);
let y = if point.y + 18. + height <= clip.y + clip.height {
point.y + 18.
} else {
(point.y - 18. - height).max(clip.y)
};
let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height));
let palette = theme.extended_palette();
renderer.fill_quad(
renderer::Quad {
bounds,
border: iced::Border {
color: palette.background.strong.color,
width: 1.,
radius: 4.into(),
},
..Default::default()
},
palette.background.weak.color,
);
let body = Rectangle::new(
Point::new(x + PAD, y + PAD),
Size::new(width - PAD * 2., body_height),
);
renderer.fill_paragraph(
&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))]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,6 @@
mod controller;
pub mod interaction;
pub mod query;
pub mod selection;
pub mod symbols;
pub use controller::*;
use iced::widget::text_editor::{Cursor, Position};
@@ -12,7 +12,6 @@ pub enum SymbolEvent {
Close,
Choose {
stamp: query::QueryStamp,
visit: Id,
reference: bool,
index: usize,
},
@@ -34,7 +33,7 @@ pub struct HistoryEntry {
pub cursor: Cursor,
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone)]
pub struct Request {
pub job: Id,
pub interaction: Id,
@@ -61,15 +60,12 @@ 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>,
@@ -80,12 +76,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,
@@ -220,22 +220,12 @@ pub struct ReferenceRow {
pub evidence: Evidence,
}
/// Exact lexical occurrence that answered the query, independent of row limits.
/// The optional @ marker is separate from the alias; comments between them do
/// not become part of either hit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Origin {
pub span: Span,
pub marker: Option<Span>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Results {
pub stamp: QueryStamp,
pub action: Action,
pub origin_path: String,
pub origin_offset: usize,
pub origin: Option<Origin>,
pub alias: Option<String>,
pub scope: Scope,
pub disposition: Disposition,
@@ -833,28 +823,10 @@ impl Snapshot {
.ok_or(QueryError::MissingFile)?;
let index = self.index()?;
let hit = index.hit(file, query.byte_offset)?;
let (alias, origin) = match hit {
Some(symbols::Hit::Definition(handle)) => {
let definition = index.definition(handle)?;
(
Some(definition.alias),
Some(Origin {
span: definition.alias_location.span,
marker: None,
}),
)
}
Some(symbols::Hit::Reference(handle)) => {
let reference = index.reference(handle)?;
(
Some(reference.alias),
Some(Origin {
span: reference.alias_location.span,
marker: Some(reference.at.span),
}),
)
}
None => (None, None),
let alias = match hit {
Some(symbols::Hit::Definition(handle)) => Some(index.definition(handle)?.alias),
Some(symbols::Hit::Reference(handle)) => Some(index.reference(handle)?.alias),
None => None,
};
let mut budget = RowBudget {
rows: self.limits.result_rows,
@@ -868,7 +840,6 @@ impl Snapshot {
action: query.action,
origin_path: query.path.to_owned(),
origin_offset: query.byte_offset,
origin,
alias: alias.map(str::to_owned),
scope: self.scope.clone(),
disposition: Disposition::NoSymbol,
@@ -107,65 +107,6 @@ fn reference_offset(fixture: &Fixture) -> usize {
fixture.text("links.arcadia").find("source @").unwrap() + "source ".len()
}
#[test]
fn origin_keeps_exact_hit_when_display_rows_are_omitted() {
let mut fixture = Fixture::standard();
fixture
.text_mut("links.arcadia")
.push_str("// no @pump hit\r\n@pump @pump @ // intervening @pump\r\n pump");
let source = fixture.text("links.arcadia");
let start = source.rfind("pump").unwrap();
let marker = source.rfind("@ // intervening").unwrap();
let limits = Limits {
result_rows: 0,
..Limits::default()
};
let snapshot = fixture.snapshot(None, false, limits);
let expected = Some(Origin {
span: Span {
start,
end: start + 4,
},
marker: Some(Span {
start: marker,
end: marker + 1,
}),
});
for offset in [start, start + 3, marker] {
let result = query(&snapshot, "links.arcadia", offset, Action::References);
assert_eq!(result.origin, expected);
assert!(result.references.is_empty());
assert!(result.omitted_references > 2);
}
let comment = source.rfind("intervening @pump").unwrap() + "intervening @".len();
assert_eq!(
query(&snapshot, "links.arcadia", comment, Action::Definition).origin,
None
);
assert_eq!(
query(&snapshot, "links.arcadia", start + 4, Action::Definition).origin,
None
);
let definition_source = fixture.text("模块/活動.arcadia");
let definition_start = definition_source.find("pump").unwrap();
assert_eq!(
query(
&snapshot,
"模块/活動.arcadia",
definition_start,
Action::Definition
)
.origin,
Some(Origin {
span: Span {
start: definition_start,
end: definition_start + 4
},
marker: None
})
);
}
#[test]
fn exact_multifile_unicode_crlf_definition_and_repeated_lexical_references() {
let fixture = Fixture::standard();
@@ -1,295 +0,0 @@
//! Effective selected-byte ranges for a committed departure from a source view.
//!
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
//! endpoints can therefore save an empty range for a visibly selected word/line.
//! Capture only after navigation admission and destination validation succeed,
//! immediately before replacing/leaving the live editor. Never use this to inspect
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
use iced::advanced::{graphics, text};
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
/// Capture the effective range for restoration on fresh Content via `move_to`.
///
/// A nonempty selection is collapsed to its start in the departing Content; text
/// is not edited. The returned Normal range preserves selected bytes and typing
/// replacement, not Word/Line gesture-extension mode or an interior click point.
/// Unequal raw endpoint direction is preserved; coincident Word/Line endpoints
/// canonically restore with the active cursor at the effective end.
///
/// The renderer constraint is intentional: the inspected Iced graphics editor
/// collapses any selection to its effective start for `Move(Left)`, and its copied
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
/// Only the selected text is temporarily copied; retained state is one Cursor.
pub fn capture<R>(content: &mut Content<R>) -> Cursor
where
R: text::Renderer<Editor = graphics::text::Editor>,
{
let original = content.cursor();
let Some(selected) = content.selection().filter(|selected| !selected.is_empty()) else {
// Moving an unselected caret Left would be a user-visible navigation.
return original;
};
let active_start = original.selection.is_some_and(|anchor| {
(original.position.line, original.position.column) < (anchor.line, anchor.column)
});
content.perform(Action::Move(Motion::Left));
let start = content.cursor().position;
let end = match selected.rsplit_once('\n') {
Some((prefix, suffix)) => Position {
line: start.line + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
column: suffix.len(),
},
None => Position {
line: start.line,
column: start.column + selected.len(),
},
};
// These sums describe an existing selection in this same allocated buffer;
// neither can exceed its line count / existing UTF-8 line length. A checked
// error after collapsing would leave a failed departure with lost selection.
debug_assert!(content.cursor().selection.is_none());
debug_assert!(start.line < content.line_count() && end.line < content.line_count());
debug_assert!((start.line, start.column) < (end.line, end.column));
Cursor {
position: if active_start { start } else { end },
selection: Some(if active_start { end } else { start }),
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::widget::text_editor::Edit;
use std::sync::Arc;
type EditorContent = Content<iced::Renderer>;
fn at(line: usize, column: usize) -> Position {
Position { line, column }
}
fn range(start: Position, end: Position, active_start: bool) -> Cursor {
Cursor {
position: if active_start { start } else { end },
selection: Some(if active_start { end } else { start }),
}
}
fn caret(content: &mut EditorContent, position: Position) {
content.move_to(Cursor {
position,
selection: None,
});
}
fn roundtrip(
source: &str,
prepare: impl Fn(&mut EditorContent),
selected: &str,
expected: Cursor,
lossy_raw: bool,
) {
let mut departing = EditorContent::with_text(source);
prepare(&mut departing);
assert_eq!(departing.text(), source);
assert_eq!(departing.selection().as_deref(), Some(selected));
let raw = departing.cursor();
// Independent direct-typing control retains the original semantic mode.
let replacement = Action::Edit(Edit::Paste(Arc::new("替🚀".into())));
let mut direct = EditorContent::with_text(source);
prepare(&mut direct);
direct.perform(replacement.clone());
let captured = capture(&mut departing);
assert_eq!(captured, expected);
assert_eq!(
departing.text(),
source,
"capture cannot edit physical source bytes"
);
assert!(departing.cursor().selection.is_none());
let mut restored = EditorContent::with_text(source);
restored.move_to(captured);
assert_eq!(restored.selection().as_deref(), Some(selected));
assert_eq!(restored.text(), source);
restored.perform(replacement);
assert_eq!(
restored.text(),
direct.text(),
"restored range must replace exactly the original selected bytes"
);
assert_eq!(restored.cursor(), direct.cursor());
if lossy_raw {
let mut old = EditorContent::with_text(source);
old.move_to(raw);
assert_ne!(
old.selection().as_deref(),
Some(selected),
"negative control must discriminate raw cursor storage"
);
} else {
assert_eq!(
captured, raw,
"ordinary directional ranges are already canonical"
);
}
}
#[test]
fn unicode_normal_ranges_preserve_forward_and_reverse_direction() {
let source = "先 αβ 救助 🚀 tail\r\n";
for active_start in [false, true] {
let expected = range(
at(0, "先 ".len()),
at(0, "先 αβ 救助 🚀".len()),
active_start,
);
roundtrip(
source,
|content| content.move_to(expected),
"αβ 救助 🚀",
expected,
false,
);
}
}
#[test]
fn repeated_unicode_words_capture_the_selected_occurrence_without_searching() {
let source = "éclair éclair éclair\r\n";
let start = "éclair ".len();
roundtrip(
source,
|content| {
caret(content, at(0, start + "écl".len()));
content.perform(Action::SelectWord);
},
"éclair",
range(at(0, start), at(0, start + "éclair".len()), false),
true,
);
}
#[test]
fn line_selection_expands_past_its_interior_unicode_caret() {
let source = "first\r\n救助 🚀 tail\r\nlast";
roundtrip(
source,
|content| {
caret(content, at(1, "救助 ".len()));
content.perform(Action::SelectLine);
},
"救助 🚀 tail",
range(at(1, 0), at(1, "救助 🚀 tail".len()), false),
true,
);
}
#[test]
fn multiline_ranges_use_copied_lf_and_utf8_columns_for_physical_crlf_and_mixed_endings() {
for source in ["pré α\r\n中 🚀\r\nfin Ω", "pré α\n中 🚀\r\nfin Ω"] {
for active_start in [false, true] {
let expected = range(at(0, "pré ".len()), at(2, "fin ".len()), active_start);
roundtrip(
source,
|content| content.move_to(expected),
"α\n中 🚀\nfin ",
expected,
false,
);
}
}
}
#[test]
fn expanded_word_and_line_modes_capture_multiline_ranges_in_both_directions() {
let source = "one alpha\r\ntwo beta\nthree gamma";
for line in [false, true] {
for reverse in [false, true] {
let (start, end, selected) = match (line, reverse) {
(false, false) => (at(0, 4), at(2, 11), "alpha\ntwo beta\nthree gamma"),
(false, true) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
(true, _) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
};
roundtrip(
source,
|content| {
caret(content, if reverse { at(2, 8) } else { at(0, 6) });
content.perform(if line {
Action::SelectLine
} else {
Action::SelectWord
});
// Iced preserves existing Word/Line mode for Select motions.
content.perform(Action::Select(if reverse {
Motion::DocumentStart
} else {
Motion::DocumentEnd
}));
},
selected,
range(start, end, reverse),
true,
);
}
}
}
#[test]
fn trailing_newline_selection_restores_endpoint_on_the_empty_last_line() {
let source = "雨\r\n🚀\r\n";
let expected = range(at(0, 0), at(2, 0), false);
roundtrip(
source,
|content| content.perform(Action::SelectAll),
"雨\n🚀\n",
expected,
false,
);
}
#[test]
fn absent_and_empty_selection_do_not_move_the_departing_caret() {
for source in ["", "\r\n", "alpha\r\n\r\ntail"] {
for empty_selection in [false, true] {
let mut content = EditorContent::with_text(source);
let position = if source.starts_with("alpha") {
at(1, 0)
} else {
at(0, 0)
};
caret(&mut content, position);
if empty_selection {
content.perform(Action::SelectLine);
assert_eq!(content.selection().as_deref(), Some(""));
} else {
assert!(content.selection().is_none());
}
let raw = content.cursor();
let selected = content.selection();
assert_eq!(capture(&mut content), raw);
assert_eq!(content.cursor(), raw);
assert_eq!(content.selection(), selected);
assert_eq!(content.text(), source);
}
}
for empty_normal in [false, true] {
let mut content = EditorContent::with_text("éclair");
let position = at(0, "écl".len());
content.move_to(Cursor {
position,
selection: empty_normal.then_some(position),
});
let raw = content.cursor();
let selected = content.selection();
assert_eq!(capture(&mut content), raw);
assert_eq!(
content.cursor(),
raw,
"an unselected interior caret must not move left"
);
assert_eq!(content.selection(), selected);
}
}
}
@@ -191,6 +191,13 @@ pub struct AuthoredAnchor {
}
impl AuthoredAnchor {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested authored-anchor API; editor Back uses exact buffer generations."
)
)]
pub fn id(self) -> Id {
self.id
}
-3
View File
@@ -1,3 +0,0 @@
//! Pure preview and exact origin mapping for the guarded alias-rename controller.
pub mod origin;
pub mod preview;
@@ -1,278 +0,0 @@
//! Map a role-proven alias occurrence through the exact prepared source change.
//! This is cursor restoration, not target resolution or commit authority.
use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass};
use std::fmt;
const LIMITS: OutlineLimits = OutlineLimits {
max_source_bytes: 64 * 1024 * 1024,
max_tokens: 1_000_000,
max_declarations: 250_000,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginError {
Budget,
InvalidSource,
MissingOrigin,
ChangedStructure,
}
impl fmt::Display for OriginError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.",
Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.",
Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.",
Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.",
})
}
}
impl std::error::Error for OriginError {}
fn word(class: TokenClass) -> bool {
matches!(class, TokenClass::Identifier | TokenClass::Keyword)
}
/// The controller passes the exact alias span returned by the admitted symbol
/// hit. `marker` is Some only for a reference (its separate @ span), including
/// invocation with the caret on @. A declaration passes None.
pub fn map(
before: &str,
after: &str,
old_alias: &str,
new_alias: &str,
origin: Span,
marker: Option<Span>,
) -> Result<Span, OriginError> {
let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?;
let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?;
if old.has_lexical_errors || new.has_lexical_errors {
return Err(OriginError::InvalidSource);
}
if old.tokens.len() != new.tokens.len() {
return Err(OriginError::ChangedStructure);
}
let roles_before = roles(&old);
let roles_after = roles(&new);
let mut mapped = None;
for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() {
let left = &before[a.span.start..a.span.end];
let right = &after[b.span.start..b.span.end];
let alias_pair = word(a.class)
&& word(b.class)
&& left == old_alias
&& right == new_alias
&& roles_before[index].is_some()
&& roles_after[index].is_some()
&& roles_before[index].map(|role| role.is_some())
== roles_after[index].map(|role| role.is_some());
if a.span == origin {
if !alias_pair || roles_before[index] != Some(marker) {
return Err(OriginError::MissingOrigin);
}
mapped = Some(b.span);
}
if !alias_pair && (left != right || a.class != b.class) {
return Err(OriginError::ChangedStructure);
}
}
mapped.ok_or(OriginError::MissingOrigin)
}
/// Outer None is not an alias role; Some(None) is a declaration alias;
/// Some(Some(span)) is a live @ reference with its exact separate marker.
/// Scan sorted declaration spans and tokens together, without repeated searches.
fn roles(outline: &Outline<'_>) -> Vec<Option<Option<Span>>> {
let mut result = vec![None; outline.tokens.len()];
let mut declarations = outline.declarations.iter().peekable();
let mut significant = None;
for (index, token) in outline.tokens.iter().enumerate() {
while declarations
.peek()
.is_some_and(|d| d.alias_span.start < token.span.start)
{
declarations.next();
}
if word(token.class) {
if declarations
.peek()
.is_some_and(|d| d.alias_span == token.span)
{
result[index] = Some(None);
} else if let Some(previous) = significant {
let span: Span = previous;
if &outline.source[span.start..span.end] == "@" {
result[index] = Some(Some(span));
}
}
}
if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) {
significant = Some(token.span);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn occurrence(source: &str, needle: &str, which: usize) -> Span {
let start = source.match_indices(needle).nth(which).unwrap().0;
Span {
start,
end: start + needle.len(),
}
}
#[test]
fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() {
let before =
"oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n";
let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n";
let start = before.rfind(" a;").unwrap() + 1;
let marker = before.rfind("@ //").unwrap();
let mapped = map(
before,
after,
"a",
"renamed",
Span {
start,
end: start + 1,
},
Some(Span {
start: marker,
end: marker + 1,
}),
)
.unwrap();
assert_eq!(&after[mapped.start..mapped.end], "renamed");
assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1);
assert_eq!(
before[..start].bytes().filter(|b| *b == b'\n').count(),
after[..mapped.start]
.bytes()
.filter(|b| *b == b'\n')
.count()
);
}
#[test]
fn preserves_final_same_line_occurrence_not_first_or_prose() {
let before = "oa_activity a \"a @a\" {} source @a; target @a;";
let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;";
let start = before.rfind("@a").unwrap();
let result = map(
before,
after,
"a",
"longer",
Span {
start: start + 1,
end: start + 2,
},
Some(Span {
start,
end: start + 1,
}),
)
.unwrap();
assert_eq!(result.start, after.rfind("@longer").unwrap() + 1);
let prose = occurrence(before, "a @a", 0);
assert!(
map(
before,
after,
"a",
"longer",
Span {
start: prose.start,
end: prose.start + 1
},
None
)
.is_err()
);
}
#[test]
fn permits_identifier_keyword_transitions_in_alias_roles() {
let before = "oa_activity a \"Name\" {} source @a;";
let alias = occurrence(before, "a \"Name", 0);
let origin = Span {
start: alias.start,
end: alias.start + 1,
};
for replacement in ["none", "include", "oa_activity"] {
assert!(oa_core::valid_alias(replacement));
assert_eq!(
language::tokenize(replacement)[0].class,
TokenClass::Keyword
);
let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};");
let mapped = map(before, &after, "a", replacement, origin, None).unwrap();
assert_eq!(&after[mapped.start..mapped.end], replacement);
assert_eq!(
map(&after, before, replacement, "a", mapped, None).unwrap(),
origin
);
}
}
#[test]
fn rejects_changed_trivia_non_alias_words_and_wrong_marker() {
let before = "oa_activity a \"Name\" {} source @a;";
let after = "oa_activity b \"Name\" {} source @b;";
let start = before.rfind("@a").unwrap();
let span = Span {
start: start + 1,
end: start + 2,
};
let marker = Some(Span {
start,
end: start + 1,
});
assert_eq!(
map(
before,
&after.replace("Name", "Other"),
"a",
"b",
span,
marker
),
Err(OriginError::ChangedStructure)
);
assert_eq!(
map(
before,
&after.replace(" source", " source"),
"a",
"b",
span,
marker
),
Err(OriginError::ChangedStructure)
);
assert_eq!(
map(before, after, "a", "b", span, None),
Err(OriginError::MissingOrigin)
);
assert!(
map(
before,
after,
"a",
"b",
Span {
start: 0,
end: usize::MAX
},
marker
)
.is_err()
);
}
}
@@ -1,578 +0,0 @@
//! Complete, bounded line-ordinal previews of an already prepared alias rename.
//!
//! Source text remains in the caller's immutable before/after snapshots. This
//! module owns only paths and indexes; it is not rename or commit authority.
use oa_core::source_project::SourceBundle;
use std::{fmt, ops::Range};
const CONTEXT: usize = 3;
// A byte-grid boundary can move back by at most three UTF-8 continuation bytes,
// so a 252-byte grid yields fragments of at most 255 bytes, independent of input.
const SEGMENT_GRID: usize = 252;
/// Count all bounded fragments of exact source text; an empty line has one
/// empty fragment. Page these as well as lines, never mount an unbounded line.
pub fn segment_count(text: &str) -> usize {
text.len().div_ceil(SEGMENT_GRID).max(1)
}
/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the
/// original bytes, including CRLF. No ellipsis or truncation replaces content.
pub fn text_segment(text: &str, index: usize) -> Option<&str> {
if index >= segment_count(text) {
return None;
}
let mut start = index.checked_mul(SEGMENT_GRID)?;
let mut end = start.saturating_add(SEGMENT_GRID).min(text.len());
while !text.is_char_boundary(start) {
start -= 1;
}
while !text.is_char_boundary(end) {
end -= 1;
}
text.get(start..end)
}
const LIMITS: Limits = Limits {
files: 256,
changed_lines: 50_000,
display_bytes: 8 * 1024 * 1024,
};
#[derive(Clone, Copy)]
struct Limits {
files: usize,
changed_lines: usize,
display_bytes: usize,
}
#[derive(Debug)]
pub struct Preview {
files: Vec<File>,
changed_lines: usize,
display_bytes: usize,
}
#[derive(Debug)]
pub struct File {
path: String,
hunks: Vec<Hunk>,
}
#[derive(Debug)]
pub struct Hunk {
lines: Vec<Line>,
}
#[derive(Debug)]
pub struct Line {
number: usize,
before: Range<usize>,
after: Range<usize>,
changed: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum PreviewError {
MembershipChanged,
LineCountChanged(String),
LineEndingChanged { path: String, line: usize },
TooManyFiles { limit: usize },
TooManyChangedLines { limit: usize },
TooManyDisplayBytes { limit: usize },
}
impl fmt::Display for PreviewError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MembershipChanged => write!(
f,
"Rename preview refused: the entry or source file membership changed. No rename was applied."
),
Self::LineCountChanged(path) => write!(
f,
"Rename preview refused: the line count changed in {path}. No rename was applied."
),
Self::LineEndingChanged { path, line } => write!(
f,
"Rename preview refused: the line ending changed in {path}:{line}. No rename was applied."
),
Self::TooManyFiles { limit } => write!(
f,
"Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied."
),
Self::TooManyChangedLines { limit } => write!(
f,
"Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied."
),
Self::TooManyDisplayBytes { limit } => write!(
f,
"Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied."
),
}
}
}
impl std::error::Error for PreviewError {}
impl Preview {
pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result<Self, PreviewError> {
Self::with_limits(before, after, LIMITS)
}
fn with_limits(
before: &SourceBundle,
after: &SourceBundle,
limits: Limits,
) -> Result<Self, PreviewError> {
if before.entry != after.entry
|| !before.files.contains_key(&before.entry)
|| !before.files.keys().eq(after.files.keys())
{
return Err(PreviewError::MembershipChanged);
}
let mut preview = Self {
files: Vec::new(),
changed_lines: 0,
display_bytes: 0,
};
for ((path, old), (_, new)) in before.files.iter().zip(&after.files) {
if old == new {
continue;
}
if preview.files.len() == limits.files {
return Err(PreviewError::TooManyFiles {
limit: limits.files,
});
}
// A changed source line can contain arbitrarily many renamed tokens.
// Count the line once; this count is never a semantic-reference count.
let mut changed = Vec::new();
let mut old_lines = Lines::new(old);
let mut new_lines = Lines::new(new);
let mut count = 0;
loop {
match (old_lines.next(), new_lines.next()) {
(None, None) => break,
(Some(a), Some(b)) => {
count += 1;
if old[a.content_end..a.end] != new[b.content_end..b.end] {
return Err(PreviewError::LineEndingChanged {
path: path.clone(),
line: count,
});
}
if old[a.start..a.end] != new[b.start..b.end] {
if preview.changed_lines == limits.changed_lines {
return Err(PreviewError::TooManyChangedLines {
limit: limits.changed_lines,
});
}
preview.changed_lines += 1;
changed.push(count - 1);
}
}
_ => return Err(PreviewError::LineCountChanged(path.clone())),
}
}
// Merge touching context intervals in order, without pairwise diff.
// At most 7 indexed lines per changed line are retained; unchanged
// lines outside these intervals are scanned but never indexed.
let mut ranges: Vec<Range<usize>> = Vec::new();
for line in changed {
let range =
line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count);
if let Some(previous) = ranges.last_mut()
&& range.start <= previous.end
{
previous.end = previous.end.max(range.end);
continue;
}
ranges.push(range);
}
preview.charge(path.len(), limits)?;
let mut file = File {
path: path.clone(),
hunks: Vec::new(),
};
let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate();
for range in ranges {
let mut hunk = Hunk { lines: Vec::new() };
for (ordinal, (a, b)) in pairs.by_ref() {
if ordinal < range.start {
continue;
}
// Charge exact UTF-8 bytes, including preserved endings and
// both copies of shared context, before adding the index.
// No render-time omission or shortening grants admission.
preview.charge(a.end - a.start, limits)?;
preview.charge(b.end - b.start, limits)?;
hunk.lines.push(Line {
number: ordinal + 1,
changed: old[a.start..a.end] != new[b.start..b.end],
before: a.start..a.end,
after: b.start..b.end,
});
if ordinal + 1 == range.end {
break;
}
}
file.hunks.push(hunk);
}
preview.files.push(file);
}
Ok(preview)
}
fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> {
let Some(total) = self.display_bytes.checked_add(bytes) else {
return Err(PreviewError::TooManyDisplayBytes {
limit: limits.display_bytes,
});
};
if total > limits.display_bytes {
return Err(PreviewError::TooManyDisplayBytes {
limit: limits.display_bytes,
});
}
self.display_bytes = total;
Ok(())
}
/// All admitted files in source path order; suitable for a file picker/search.
pub fn files(&self) -> &[File] {
&self.files
}
/// Changed source lines, not occurrences, declarations or semantic uses.
pub fn changed_line_count(&self) -> usize {
self.changed_lines
}
/// Exact displayed source bytes for both sides plus each stored path once.
/// Index metadata is separately bounded by 7 lines per changed line.
pub fn display_bytes(&self) -> usize {
self.display_bytes
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
}
impl File {
pub fn path(&self) -> &str {
&self.path
}
/// Every hunk is present; the caller may page these indexes, never truncate.
pub fn hunks(&self) -> &[Hunk] {
&self.hunks
}
}
impl Hunk {
/// The complete ordered line slice supports paging even a large single hunk.
pub fn lines(&self) -> &[Line] {
&self.lines
}
}
impl Line {
/// One-based source line number, identical on both sides.
pub fn number(&self) -> usize {
self.number
}
pub fn changed(&self) -> bool {
self.changed
}
/// Borrow from the immutable original file; includes its exact line ending.
pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> {
original.get(self.before.clone())
}
/// Borrow from the immutable candidate file; includes its exact line ending.
pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> {
candidate.get(self.after.clone())
}
}
struct RawLine {
start: usize,
content_end: usize,
end: usize,
}
struct Lines<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Lines<'a> {
fn new(text: &'a str) -> Self {
Self {
bytes: text.as_bytes(),
offset: 0,
}
}
}
impl Iterator for Lines<'_> {
type Item = RawLine;
fn next(&mut self) -> Option<Self::Item> {
if self.offset == self.bytes.len() {
return None;
}
let start = self.offset;
// The compiler and source navigation count LF, not bare CR, as a line
// boundary. Preserve CRLF as an ending and bare CR as source content.
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
self.offset += 1;
}
let mut content_end = self.offset;
if self.offset < self.bytes.len() {
if self.offset > start && self.bytes[self.offset - 1] == b'\r' {
content_end -= 1;
}
self.offset += 1;
}
Some(RawLine {
start,
content_end,
end: self.offset,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bundle(source: &str) -> SourceBundle {
SourceBundle {
entry: "救援.arcadia".into(),
files: [("救援.arcadia".into(), source.into())].into(),
}
}
#[test]
fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() {
let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200));
let pieces: Vec<_> = (0..segment_count(&text))
.map(|i| text_segment(&text, i).unwrap())
.collect();
assert!(pieces.len() > 2);
assert!(pieces.iter().all(|part| part.len() <= 255));
assert_eq!(pieces.concat(), text);
assert_eq!(text_segment(&text, segment_count(&text)), None);
assert_eq!(text_segment(&text, usize::MAX), None);
assert_eq!(segment_count(""), 1);
assert_eq!(text_segment("", 0), Some(""));
}
#[test]
fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() {
let old = bundle("// 救援\r\n@a @a\ncontext\r@a");
let new = bundle("// 救援\r\n@long @long\ncontext\r@long");
let preview = Preview::build(&old, &new).unwrap();
assert_eq!(preview.changed_line_count(), 2);
let file = &preview.files()[0];
assert_eq!(file.path(), "救援.arcadia");
assert_eq!(file.hunks().len(), 1);
let lines = file.hunks()[0].lines();
assert_eq!(
lines.iter().map(Line::number).collect::<Vec<_>>(),
[1, 2, 3]
);
assert_eq!(
lines[0].before_text(&old.files[&old.entry]),
Some("// 救援\r\n")
);
assert_eq!(
lines[1].after_text(&new.files[&new.entry]),
Some("@long @long\n")
);
assert_eq!(
lines[2].after_text(&new.files[&new.entry]),
Some("context\r@long")
);
assert!(lines[1].changed() && lines[2].changed());
assert!(!lines[0].changed());
assert_eq!(
preview.display_bytes(),
old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len()
);
}
#[test]
fn first_and_last_lines_form_complete_separate_hunks_and_pages() {
let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na");
let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong");
let preview = Preview::build(&old, &new).unwrap();
let hunks = preview.files()[0].hunks();
assert_eq!(hunks.len(), 2);
let page_numbers: Vec<_> = hunks
.iter()
.flat_map(|h| h.lines().chunks(2))
.flat_map(|page| page.iter().map(Line::number))
.collect();
assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]);
assert_eq!(
hunks[1].lines()[3].after_text(&new.files[&new.entry]),
Some("long")
);
}
#[test]
fn nearby_changes_merge_and_all_changed_files_are_reachable() {
let mut old = bundle("a\n1\n2\n3\n4\n5\na\n");
let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n");
old.files.insert("other.arcadia".into(), "a a a".into());
new.files.insert("other.arcadia".into(), "b b b".into());
let preview = Preview::build(&old, &new).unwrap();
assert_eq!(preview.changed_line_count(), 3);
assert_eq!(preview.files().len(), 2);
assert_eq!(preview.files()[0].path(), "other.arcadia");
assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1);
assert_eq!(preview.files()[1].hunks().len(), 1);
assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7);
}
#[test]
fn unchanged_and_empty_sources_have_no_preview() {
for source in ["", "a\r\nb\n", "a"] {
let original = bundle(source);
let preview = Preview::build(&original, &original).unwrap();
assert!(preview.is_empty());
assert_eq!(preview.changed_line_count(), 0);
assert_eq!(preview.display_bytes(), 0);
}
}
#[test]
fn refuses_membership_entry_line_count_and_line_ending_changes() {
let old = bundle("a\nb");
let mut new = old.clone();
new.files.insert("extra.arcadia".into(), "".into());
assert_eq!(
Preview::build(&old, &new).unwrap_err(),
PreviewError::MembershipChanged
);
let mut new = old.clone();
new.entry = "missing.arcadia".into();
assert_eq!(
Preview::build(&old, &new).unwrap_err(),
PreviewError::MembershipChanged
);
assert!(matches!(
Preview::build(&old, &bundle("a\nb\nc")),
Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_))
));
assert!(matches!(
Preview::build(&bundle("a\nb\n"), &bundle("a\n")),
Err(PreviewError::LineCountChanged(_))
));
for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] {
assert_eq!(
Preview::build(&bundle(a), &bundle(b)).unwrap_err(),
PreviewError::LineEndingChanged {
path: old.entry.clone(),
line: 1
}
);
}
}
#[test]
fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() {
let old = bundle("a\ncontext\na");
let new = bundle("bbb\ncontext\nbbb");
let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len();
let limits = Limits {
files: 1,
changed_lines: 2,
display_bytes: exact,
};
assert!(Preview::with_limits(&old, &new, limits).is_ok());
assert_eq!(
Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(),
PreviewError::TooManyFiles { limit: 0 }
);
assert_eq!(
Preview::with_limits(
&old,
&new,
Limits {
changed_lines: 1,
..limits
}
)
.unwrap_err(),
PreviewError::TooManyChangedLines { limit: 1 }
);
assert_eq!(
Preview::with_limits(
&old,
&new,
Limits {
display_bytes: exact - 1,
..limits
}
)
.unwrap_err(),
PreviewError::TooManyDisplayBytes { limit: exact - 1 }
);
let long = bundle(&"a".repeat(LIMITS.display_bytes));
assert!(matches!(
Preview::build(&bundle("b"), &long),
Err(PreviewError::TooManyDisplayBytes { .. })
));
}
#[test]
fn limits_apply_to_the_whole_preview_not_each_file() {
let mut old = bundle("a");
let mut new = bundle("b");
old.files.insert("second.arcadia".into(), "a".into());
new.files.insert("second.arcadia".into(), "b".into());
let limits = Limits {
files: 2,
changed_lines: 2,
display_bytes: old.entry.len() + "second.arcadia".len() + 4,
};
let complete = Preview::with_limits(&old, &new, limits).unwrap();
assert_eq!(complete.files().len(), 2);
assert_eq!(complete.changed_line_count(), 2);
assert_eq!(complete.display_bytes(), limits.display_bytes);
assert!(matches!(
Preview::with_limits(&old, &new, Limits { files: 1, ..limits }),
Err(PreviewError::TooManyFiles { limit: 1 })
));
assert!(matches!(
Preview::with_limits(
&old,
&new,
Limits {
changed_lines: 1,
..limits
}
),
Err(PreviewError::TooManyChangedLines { limit: 1 })
));
assert!(matches!(
Preview::with_limits(
&old,
&new,
Limits {
display_bytes: limits.display_bytes - 1,
..limits
}
),
Err(PreviewError::TooManyDisplayBytes { .. })
));
}
}
+425 -83
View File
@@ -1,5 +1,4 @@
mod source_symbols;
mod text_workspace;
pub(crate) mod traceability;
use crate::{
app::{App, Choice, FileOperation, Message, Page, Pending},
@@ -277,7 +276,7 @@ pub fn view(app: &App) -> View<'_, Message> {
p.relations.len()
)),
action(
&format!("{} model checks", app.issue_count()),
&format!("{} checks", app.issue_count()),
Message::Page(Page::Validation)
)
]
@@ -351,13 +350,6 @@ pub fn view(app: &App) -> View<'_, Message> {
if app.pending.is_some() {
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
}
if app.source_rename.open() {
return modal_width(
root,
crate::app::source_rename::dialog(app),
(app.window_size.width - 32.).clamp(120., 760.),
);
}
root
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
@@ -1011,87 +1003,235 @@ fn source_files(app: &App) -> View<'_, Message> {
}
fn source(app: &App) -> View<'_, Message> {
use crate::app::text_workspace::DrawerTab;
use oa_core::source_project::SourceLocation;
let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems);
let publication = app
.text_workspace
.diagnostic_publication()
.filter(|publication| visible && publication.source() == app.source_key());
let mut diagnostics = column![].spacing(3).width(Fill);
if visible {
if publication.is_none() && !app.source_diagnostics.is_empty() {
diagnostics = diagnostics
.push(text("Source changed. Waiting for current source checks…").size(12));
}
if app.source_diagnostics.is_empty() {
diagnostics = diagnostics.push(text("No source problems reported.").size(12));
}
for issue in &app.source_diagnostics {
let location = SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
};
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,
))
use crate::source_navigation::Event as SourceEvent;
let navigation: View<'_, Message> = if app.source_navigation.open {
column![
row![
text_input("Find literal text…", &app.source_navigation.query)
.id("source-find")
.on_input(|query| Message::SourceNavigation(SourceEvent::Query(query)))
.on_submit(Message::SourceNavigation(SourceEvent::Next(false)))
.padding(8)
.size(12)
.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())),
);
}
.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;
}
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()),
));
}
}
let guard = crate::diagnostic_input::Guard::new(
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
diagnostics,
);
text_workspace::view(app, source_symbols::panel(app), guard.into())
let editor = widget::text_editor(&app.source_editor)
.id(app.source_editor_id())
.on_action(Message::SourceEdit)
.key_binding({
let context = app.source_symbol_context();
move |press| source_binding(press, &context)
})
.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 model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
label(if app.source_dirty() {
"Drafts pending"
} else {
"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))),
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)
}
pub(crate) fn source_binding(
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()
}
fn source_binding(
press: widget::text_editor::KeyPress,
context: &crate::source_navigation::SymbolContext,
rename: Option<&crate::app::source_rename::Begin>,
) -> Option<widget::text_editor::Binding<Message>> {
use crate::source_navigation::{SymbolEvent, query::Action};
use iced::keyboard::{Key, key::Named};
use widget::text_editor::{Binding, Status};
if matches!(press.status, Status::Focused { .. })
&& press.modifiers.is_empty()
&& press.key == Key::Named(Named::F2)
&& let Some(begin) = rename
{
return Some(Binding::Custom(Message::SourceRename(
crate::app::source_rename::Event::Begin(begin.clone()),
)));
}
// Windows owns Alt+Space for its window menu. Iced's default binding would
// otherwise insert a supplied space payload, even after that menu closes.
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
@@ -1133,15 +1273,16 @@ pub(crate) fn source_binding(
}
#[cfg(all(test, debug_assertions))]
pub(crate) mod source_tests {
mod source_tests {
use super::*;
use iced::advanced::{
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
};
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
use iced::widget::column;
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
pub(crate) struct FontLayout;
struct FontLayout;
impl renderer::Renderer for FontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
@@ -1183,6 +1324,207 @@ 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() {
@@ -1270,7 +1612,7 @@ pub(crate) mod source_tests {
.size(14)
.padding(16)
.height(Fill)
.key_binding(|press| source_binding(press, &context, None))
.key_binding(|press| source_binding(press, &context))
.into();
let mut tree = core_widget::Tree::new(editor.as_widget());
let size = Size::new(600., 240.);
+151 -276
View File
@@ -2,151 +2,80 @@ use crate::{
app::{App, Message},
source_navigation::{
SymbolEvent,
query::{Declaration, Disposition, Evidence, Identity, Membership, Position},
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
},
style,
};
use iced::{
Element, Fill,
widget::{button, column, container, scrollable, text, tooltip},
widget::{Space, button, column, container, row, scrollable, text, tooltip},
};
pub(super) fn panel(app: &App) -> Element<'_, Message> {
use crate::app::text_workspace::{DrawerTab, Event};
use std::collections::BTreeSet;
let state = &app.source_symbols;
let visible = app.text_workspace.drawer() == Some(DrawerTab::References);
let presentation = app.text_workspace.references();
let mut content = column![].spacing(0).width(Fill);
if visible && let Some(notice) = &state.notice {
content = content.push(text(notice.clone()).size(12));
if !state.open
|| (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none())
{
return column![].into();
}
if visible && let Some(results) = &state.results {
let stale = results.stamp.snapshot != app.source_key();
let enabled = !stale && app.source_navigation_available();
let files: BTreeSet<_> = results
.definitions
.iter()
.map(|row| row.path.as_str())
.chain(results.references.iter().map(|row| row.path.as_str()))
.collect();
let mut ordered: Vec<_> = files.iter().copied().collect();
// Keep the query's captured origin first even after navigating elsewhere.
if let Some(origin) = state
.display_request
.as_ref()
.map(|request| request.path.as_str())
{
ordered.sort_by_key(|path| (*path != origin, *path));
}
for path in ordered {
let collapsed = presentation.collapsed(path);
let count = results
.definitions
.iter()
.filter(|row| row.path == path)
.count()
+ results
.references
.iter()
.filter(|row| row.path == path)
.count();
let suffix = distinguishing_path(path, &files);
content = content.push(
button(
text(format!(
"{} {suffix} · {count}",
if collapsed { "▸" } else { "▾" }
))
.size(12)
.wrapping(iced::widget::text::Wrapping::None),
)
.height(24)
.width(Fill)
.padding([2, 6])
let title = state.results.as_ref().map_or_else(
|| "Source symbols".to_owned(),
|results| {
let action = match results.action {
query::Action::Definition => "Definitions",
query::Action::References => "References",
};
results.alias.as_ref().map_or_else(
|| action.to_owned(),
|alias| format!("{action} · @{}", preview(alias, 40)),
)
},
);
let mut content = column![
row![
text(title).size(13),
Space::new().width(Fill),
button(text("Close").size(11))
.padding([3, 8])
.style(style::nav)
.on_press_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]),
);
.on_press(app.source_symbol_message(SymbolEvent::Close)),
]
.align_y(iced::Center)
]
.spacing(5);
if let Some(notice) = &state.notice {
content = content.push(super::label(preview(notice, 240)));
}
if state.in_flight.is_some() {
content = content.push(super::label("Finding source symbols…"));
} else if let Some(results) = &state.results {
let stale = results.stamp.snapshot != app.source_key();
if stale {
content = content.push(super::label(
"Stale results — source changed. Run Definition or References again.",
));
}
let scope = &results.scope;
content = content.push(super::label(format!(
"{} · {} · {} included / {} detached / {} unverified files · {} scope issues",
if scope.partial() {
"Partial scope"
} else {
"Complete include scope"
},
if !scope.applied {
"Unapplied drafts"
} else if scope.validated {
"Validated sources"
} else {
"Draft sources"
},
scope.included_files,
scope.detached_files,
scope.unverified_files,
scope.issues.len(),
)));
let explanation = match results.disposition {
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
@@ -158,113 +87,89 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> {
"Lexical uses of this alias; these do not establish a semantic target."
}
};
content = content.push(text(explanation).size(12));
if stale {
content = content.push(
text("Source changed. Run Definition or References again for current locations.")
.size(12),
);
}
content = content.push(super::label(explanation));
if results.total_definitions > 1 {
content = content.push(
text("Alias collision: uses are not attributed to a single declaration.").size(12),
);
content = content.push(super::label(format!(
"Alias collision: {} declarations. Uses are not attributed to a single declaration.",
results.total_definitions,
)));
}
content = content
.push(
text(format!(
"{} · {} included / {} detached / {} unverified files · {} scope issues",
if scope.partial() {
"Partial include scope"
} else {
"Complete include scope"
},
scope.included_files,
scope.detached_files,
scope.unverified_files,
scope.issues.len()
))
.size(12),
)
.push(
text(if !scope.applied {
"Unapplied drafts"
} else if scope.validated {
"Validated sources"
} else {
"Draft sources"
})
.size(12),
)
.push(
text(counts(
"Declarations",
results.definitions.len(),
results.total_definitions,
results.omitted_definitions,
))
.size(12),
)
.push(
text(counts(
"Lexical uses",
results.references.len(),
results.total_references,
results.omitted_references,
))
.size(12),
);
}
let identity = if visible {
state
.display_request
.as_ref()
.map(|request| (request.job, app.text_workspace.drawer_visit()))
} else {
None
};
let guarded = crate::diagnostic_input::Guard::new(identity, content);
let mut body = scrollable(
container(guarded)
.width(Fill)
.padding(iced::Padding::ZERO.right(14)),
)
.id(app.text_workspace.reference_scroll_id())
.height(Fill)
.width(Fill);
if let Some(stamp) = presentation.stamp() {
let visit = app.text_workspace.drawer_visit();
body = body.on_scroll(move |viewport| {
Message::TextWorkspace(Event::ReferenceScrolled {
stamp,
visit,
y: viewport.absolute_offset().y,
})
});
}
body.into()
}
/// Shortest suffix that still distinguishes duplicate basenames and suffix paths.
fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str {
let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1));
for start in starts.collect::<Vec<_>>().into_iter().rev() {
let candidate = &path[start..];
if files
.iter()
.filter(|other| {
**other == candidate
|| other
.strip_suffix(candidate)
.is_some_and(|prefix| prefix.ends_with('/'))
})
.count()
== 1
{
return candidate;
let enabled = !stale && !app.busy;
let mut rows = column![super::label(counts(
"Declarations",
results.definitions.len(),
results.total_definitions,
results.omitted_definitions,
))]
.spacing(4);
for (index, definition) in results.definitions.iter().enumerate() {
rows = rows.push(location_row(
declaration_title(&definition.declaration),
&definition.path,
definition.position,
provenance(
definition.membership,
definition.evidence,
definition.declaration.identity,
false,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
})
}),
));
}
rows = rows.push(super::label(counts(
"Lexical uses",
results.references.len(),
results.total_references,
results.omitted_references,
)));
for (index, reference) in results.references.iter().enumerate() {
let title = reference.context.as_ref().map_or_else(
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|context| {
format!(
"@{} · inside {}",
preview(&reference.alias, 48),
declaration_title(context)
)
},
);
rows = rows.push(location_row(
title,
&reference.path,
reference.position,
provenance(
reference.membership,
reference.evidence,
reference
.context
.as_ref()
.map_or(Identity::Missing, |context| context.identity),
true,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: true,
index,
})
}),
));
}
let row_count = results.definitions.len() + results.references.len();
content =
content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0)));
}
path
container(content)
.padding(8)
.width(Fill)
.style(style::subtle)
.into()
}
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
@@ -318,24 +223,23 @@ fn location_row(
) -> Element<'static, Message> {
let line = position.line.saturating_add(1);
let column = position.byte_column.saturating_add(1);
let full =
format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)");
let location = format!("{}:{line}:{column}", preview(path, 72));
tooltip(
button(
container(
text(format!("{line}:{column} {title} · {status}"))
.size(12)
.wrapping(iced::widget::text::Wrapping::None),
)
.clip(true)
.width(Fill),
column![
text(title).size(12),
super::label(format!("{location} · {status}"))
]
.spacing(2),
)
.padding([3, 12])
.height(28)
.padding([4, 6])
.width(Fill)
.style(style::nav)
.on_press_maybe(event),
text(full).size(12),
text(format!(
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
))
.size(11),
tooltip::Position::Top,
)
.into()
@@ -354,32 +258,3 @@ 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
@@ -1,248 +0,0 @@
//! Shapes and draws the production summary, checking actual paragraph advances.
//! Native validation remains necessary for platform raster ink/fallback fonts.
use super::*;
use crate::source_navigation::query;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree};
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
use std::sync::Arc;
struct Cluster {
paragraph: String,
bytes: std::ops::Range<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct Recorder {
clips: Vec<Rectangle>,
transforms: Vec<Transformation>,
clusters: Vec<Cluster>,
}
impl Recorder {
fn transform(&self) -> Transformation {
*self.transforms.last().unwrap()
}
fn clip(&self, bounds: Rectangle) -> Rectangle {
self.clips
.last()
.unwrap()
.intersection(&(bounds * self.transform()))
.unwrap_or(Rectangle::with_size(Size::ZERO))
}
}
impl renderer::Renderer for Recorder {
fn start_layer(&mut self, bounds: Rectangle) {
self.clips.push(self.clip(bounds));
}
fn end_layer(&mut self) {
self.clips.pop().unwrap();
}
fn start_transformation(&mut self, transform: Transformation) {
self.transforms.push(self.transform() * transform);
}
fn end_transformation(&mut self) {
self.transforms.pop().unwrap();
}
fn reset(&mut self, bounds: Rectangle) {
self.clips = vec![bounds];
self.transforms = vec![Transformation::IDENTITY];
self.clusters.clear();
}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for Recorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(
&mut self,
paragraph: &Self::Paragraph,
position: Point,
_: Color,
clip: Rectangle,
) {
let buffer = paragraph.buffer();
let content = buffer
.lines
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n");
let clip = self.clip(clip);
for run in buffer.layout_runs() {
for glyph in run.glyphs {
self.clusters.push(Cluster {
paragraph: content.clone(),
bytes: glyph.start..glyph.end,
bounds: Rectangle {
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
y: position.y + run.line_top,
width: glyph.w,
height: run.line_height,
} * self.transform(),
clip,
});
}
}
}
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"];
fn draw_and_check_flags(
content: Element<'_, Message, iced::Theme, Recorder>,
width: f32,
) -> Vec<String> {
let viewport = Rectangle::with_size(Size::new(width, 24.));
let mut renderer = Recorder {
clips: vec![viewport],
transforms: vec![Transformation::IDENTITY],
clusters: Vec::new(),
};
let mut view: Element<'_, Message, iced::Theme, Recorder> =
container(content).width(Fill).height(24).clip(true).into();
let mut tree = Tree::new(view.as_widget());
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
assert_eq!(node.size(), viewport.size());
view.as_widget().draw(
&tree,
&mut renderer,
&iced::Theme::Light,
&renderer::Style::default(),
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
let mut failures = Vec::new();
for flag in FLAGS {
for byte in 0..flag.len() {
let matching: Vec<_> = renderer
.clusters
.iter()
.filter(|cluster| {
cluster
.paragraph
.find(flag)
.is_some_and(|start| cluster.bytes.contains(&(start + byte)))
})
.collect();
if matching.is_empty() {
failures.push(format!("missing shaped flag byte: {flag}[{byte}]"));
}
for cluster in matching {
let b = cluster.bounds;
let c = cluster.clip;
if !(b.x >= c.x - 0.5
&& b.y >= c.y - 0.5
&& b.x + b.width <= c.x + c.width + 0.5
&& b.y + b.height <= c.y + c.height + 0.5)
{
failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}"));
}
}
}
}
failures
}
fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> {
text(value)
.size(12)
.wrapping(widget::text::Wrapping::None)
.into()
}
#[test]
fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() {
let mut app = App::with_project(oa_core::Project::empty());
let old_key = App::with_project(oa_core::Project::empty()).source_key();
assert_ne!(old_key, app.source_key());
for alias in ["雨".repeat(32), "W".repeat(32)] {
// Unicode aliases are presentation stress: the current lexer admits
// ASCII identifiers only. Six-digit totals are within the query's
// 250,000-record cap; this does not build that many source records.
let old_value = format!(
"@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified"
);
app.source_symbols.results = Some(Arc::new(query::Results {
stamp: query::QueryStamp {
snapshot: old_key,
serial: 1,
},
action: query::Action::References,
origin_path: app.source_workspace.active.clone(),
origin_offset: 0,
origin: None,
alias: Some(alias.clone()),
scope: query::Scope {
closure_complete: false,
applied: false,
validated: false,
included_files: 28,
detached_files: 1,
unverified_files: 1,
issues: Vec::new(),
},
disposition: query::Disposition::LexicalReferences,
definitions: Vec::new(),
references: Vec::new(),
total_definitions: 100_000,
total_references: 100_000,
omitted_definitions: 100_000,
omitted_references: 100_000,
}));
for width in [840., 720.] {
let positive = draw_and_check_flags(reference_summary(&app), width);
assert!(
positive.is_empty(),
"reserved flags, width {width}, alias {alias:?}: {positive:?}"
);
// Observe the exact old presentation without predicting font widths.
// This is emitted under --nocapture, whether it clips or fits.
let old = draw_and_check_flags(old_summary(old_value.clone()), width);
println!(
"old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}",
old.len(),
old.first()
);
}
}
// Deliberate evaluator stress, beyond the production 32-character subject
// bound: require the same evaluator to reject a clipped old-style paragraph.
// This is not evidence of a production/native overflow at 840 pixels.
let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · "));
let rejected = draw_and_check_flags(old_summary(stress), 720.);
assert!(
!rejected.is_empty(),
"negative control must detect missing/clipped warning text"
);
}
-158
View File
@@ -75,164 +75,6 @@ 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
@@ -1,6 +0,0 @@
{
"git": {
"sha1": "54abf81d13fec06d4d9ac754b03ce2c3313e8a1f"
},
"path_in_vcs": "widget"
}
-31
View File
@@ -1,31 +0,0 @@
# Pinned Iced widget patch
Upstream crate: iced_widget 0.14.2, MIT. Its registry VCS record identifies
`iced-rs/iced` commit `54abf81d13fec06d4d9ac754b03ce2c3313e8a1f`, path `widget`.
The accompanying LICENSE was obtained from that exact upstream commit:
https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c3313e8a1f/LICENSE
SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`.
All copied src/assets and Cargo manifests retain their upstream bytes except
`src/text_editor.rs`, which adds one documented read-only `Content::with_editor`
callback. Original file SHA-256:
`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`.
The callback borrows the existing renderer editor and releases the borrow when
the closure returns. It adds no mutable editor access or event behavior.
ARCADIA uses this seam to project source diagnostics onto the actual painted
editor layout. It must not clone/rebuild Content or move user selection for
measurement. The isolated Windows CPU experiment verified the exact painted
editor identity and geometry plus retained Word/Line selection and scroll;
application integration/native validation are separately required.
UPSTREAM-MANIFEST.json records all files from the local pinned registry source,
including registry bookkeeping and the upstream crate lockfile not copied here.
Those omitted files are not package inputs. The workspace root Cargo.lock and
local patch path identify the resolved dependency; this patched package is not
the unmodified registry checksum. Keep LICENSE and this provenance in source
captures and generated dependency notices.
On upgrades compare the pristine upstream module and replay only this accessor.
Recheck actual editor layout, selection/input continuity and annotation tests.
Remove this patch if upstream provides equivalent read-only layout access.
-126
View File
@@ -1,126 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
name = "iced_widget"
version = "0.14.2"
authors = ["Héctor Ramón Jiménez <hector@hecrj.dev>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "The built-in widgets for iced"
homepage = "https://iced.rs"
readme = false
keywords = [
"gui",
"ui",
"graphics",
"interface",
"widgets",
]
categories = ["gui"]
license = "MIT"
repository = "https://github.com/iced-rs/iced"
[package.metadata.docs.rs]
rustdoc-args = [
"--cfg",
"docsrs",
]
all-features = true
[features]
advanced = []
canvas = ["iced_renderer/geometry"]
crisp = []
highlighter = ["dep:iced_highlighter"]
image = ["iced_renderer/image"]
lazy = ["ouroboros"]
markdown = ["dep:pulldown-cmark"]
qr_code = [
"canvas",
"dep:qrcode",
]
svg = ["iced_renderer/svg"]
wgpu = ["iced_renderer/wgpu-bare"]
[lib]
name = "iced_widget"
path = "src/lib.rs"
[dependencies.iced_highlighter]
version = "0.14.0"
optional = true
[dependencies.iced_renderer]
version = "0.14.0"
[dependencies.log]
version = "0.4"
[dependencies.num-traits]
version = "0.2"
[dependencies.ouroboros]
version = "0.18"
optional = true
[dependencies.pulldown-cmark]
version = "0.12"
optional = true
[dependencies.qrcode]
version = "0.13"
optional = true
default-features = false
[dependencies.rustc-hash]
version = "2.0"
[dependencies.thiserror]
version = "2"
[dependencies.unicode-segmentation]
version = "1.0"
[lints.clippy]
default_trait_access = "deny"
filter_map_next = "deny"
from_over_into = "deny"
large-enum-variant = "allow"
manual_let_else = "deny"
map-entry = "allow"
match-wildcard-for-single-variants = "deny"
needless_borrow = "deny"
new_without_default = "deny"
redundant-closure-for-method-calls = "deny"
result_large_err = "allow"
semicolon_if_nothing_returned = "deny"
trivially-copy-pass-by-ref = "deny"
type-complexity = "allow"
unused_async = "deny"
useless_conversion = "deny"
[lints.rust]
missing_docs = "deny"
unsafe_code = "deny"
unused_results = "deny"
[lints.rust.rust_2018_idioms]
level = "deny"
priority = -1
[lints.rustdoc]
broken_intra_doc_links = "forbid"
-51
View File
@@ -1,51 +0,0 @@
[package]
name = "iced_widget"
description = "The built-in widgets for iced"
version = "0.14.2"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
categories.workspace = true
keywords.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[features]
lazy = ["ouroboros"]
image = ["iced_renderer/image"]
svg = ["iced_renderer/svg"]
canvas = ["iced_renderer/geometry"]
qr_code = ["canvas", "dep:qrcode"]
wgpu = ["iced_renderer/wgpu-bare"]
markdown = ["dep:pulldown-cmark"]
highlighter = ["dep:iced_highlighter"]
advanced = []
crisp = []
[dependencies]
iced_renderer.workspace = true
num-traits.workspace = true
log.workspace = true
rustc-hash.workspace = true
thiserror.workspace = true
unicode-segmentation.workspace = true
ouroboros.workspace = true
ouroboros.optional = true
qrcode.workspace = true
qrcode.optional = true
pulldown-cmark.workspace = true
pulldown-cmark.optional = true
iced_highlighter.workspace = true
iced_highlighter.optional = true
-18
View File
@@ -1,18 +0,0 @@
Copyright 2019 Héctor Ramón, Iced contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-352
View File
@@ -1,352 +0,0 @@
[
{
"path": ".cargo-ok",
"bytes": 7,
"sha256": "afbf9d0f3560b0fd7795e81c42a0a79ee6b6fc67e064f77826aee642cad28d91"
},
{
"path": ".cargo_vcs_info.json",
"bytes": 100,
"sha256": "58c111ee6197295ae381d021ab7afd7ac38b02acce90d0ff0f82ae2e1e5f864c"
},
{
"path": "assets/iced-logo.svg",
"bytes": 1892,
"sha256": "8ac9e30de1b466b3d594c77c984299b1a229f6f0f010e6804139a2f41ea8345e"
},
{
"path": "Cargo.lock",
"bytes": 56700,
"sha256": "89fe393e9bbc1c6dc94796d764dfb3f864adb400607b4508b6138eb3205cce37"
},
{
"path": "Cargo.toml",
"bytes": 2622,
"sha256": "e8e6b9a72fc0f97c84b8fb2703fe6cdccd738c6c01d2fadf018a012182de93b5"
},
{
"path": "Cargo.toml.orig",
"bytes": 1109,
"sha256": "f9bed37f4aa1edebea47824c1f9678411d410a1546401a14ce6f8b4108c96448"
},
{
"path": "src/action.rs",
"bytes": 2507,
"sha256": "feb91a386073280d76eaf37dbb8e7380e5a6bf9b8003110107b32bb8f577a239"
},
{
"path": "src/button.rs",
"bytes": 21150,
"sha256": "a8019fee3ad691fb2395afcdf302bec5f9e1cc592552aff4d8c8bbd1c6149f5a"
},
{
"path": "src/canvas/program.rs",
"bytes": 3099,
"sha256": "637bbc6d10a623f355c6d44af1162a10331861806cec094e2a8f2a97313acab9"
},
{
"path": "src/canvas.rs",
"bytes": 9637,
"sha256": "1fecfd50d593c5ff9bc9317c9f1624d0c22eddc7cf39a795684bf70eb5954acd"
},
{
"path": "src/checkbox.rs",
"bytes": 20973,
"sha256": "4409c11d6473baf4baefafbb3ed253cd9353f04136cac6009422a876a84ec6b1"
},
{
"path": "src/column.rs",
"bytes": 17490,
"sha256": "f880d15ebdb33e2f6ebb6067bb0b124c8fafdc73a6e5112a6f5b5e012fddadf4"
},
{
"path": "src/combo_box.rs",
"bytes": 30694,
"sha256": "973901d05003f684be1d13da5adc88ef7e030dc31db07c1bb0717cf9210d2391"
},
{
"path": "src/container.rs",
"bytes": 18126,
"sha256": "f8162a4e1c25c602070da482ecccfd5e62f456ffe1caa5394a11243d339f6114"
},
{
"path": "src/float.rs",
"bytes": 12092,
"sha256": "9bc2d313052c97338dcdec2a5be7e0301b6261fe7975b511050fe4abeeb1c571"
},
{
"path": "src/grid.rs",
"bytes": 11862,
"sha256": "01e4deea4638fba19e3859d7ae27bd8090bca3fcee6f31c48bf5ad9a19f5c151"
},
{
"path": "src/helpers.rs",
"bytes": 60874,
"sha256": "3989cad7e901ff8171a16024d93ec40cb32e7859c2e76a98359f5f230a2fabcb"
},
{
"path": "src/image/viewer.rs",
"bytes": 14066,
"sha256": "ce372e8ba97248cea915abd52046020c0f5c318efc2af1d7f4d74958b14629bf"
},
{
"path": "src/image.rs",
"bytes": 12361,
"sha256": "dab4c761c846ba2006ba5298c9b3573b0a2787272b2f74376cde6c35ae7f3694"
},
{
"path": "src/keyed/column.rs",
"bytes": 10710,
"sha256": "09adcae868d561443e8d8b6f3cdfefd6831d8e94adf2b22c551c85b9669eb620"
},
{
"path": "src/keyed.rs",
"bytes": 2840,
"sha256": "f1e572a13d0717816a6d4467d14f07fa613956a6bf9a2b234d79789b0bcb1d57"
},
{
"path": "src/lazy/cache.rs",
"bytes": 377,
"sha256": "59445853fb59941d73904689e5ee34bc6e9f7159c58bbea0cac8ef87f69a1b10"
},
{
"path": "src/lazy/component.rs",
"bytes": 20440,
"sha256": "05e4e793f3462e5f164ef1191a5cf2c6daf32d410aeebbe1bb4e951fb04fdef7"
},
{
"path": "src/lazy/helpers.rs",
"bytes": 1292,
"sha256": "d6bab15685469619bc3833afacc1df22112adacbdf944d6dbb5ea1b9e5f9d993"
},
{
"path": "src/lazy.rs",
"bytes": 11530,
"sha256": "8c127fcd044cf887564856ce3a38ecf41ff3ff24d832d72095ec4e92f9907673"
},
{
"path": "src/lib.rs",
"bytes": 2933,
"sha256": "4da7f1a83cd8edc30bd9983e59ba3537f44a475814d0b9c80dcf7e97873badb4"
},
{
"path": "src/markdown.rs",
"bytes": 50899,
"sha256": "5269582001d26d79f9de4e6ee60ebed587adf552f6f91c8ffcd757782319d161"
},
{
"path": "src/mouse_area.rs",
"bytes": 12666,
"sha256": "01686e65f76a4b89356ec6e5900b4411c856b15a56236ed3f6ef410ec3a4983d"
},
{
"path": "src/overlay/menu.rs",
"bytes": 19043,
"sha256": "89719b05b75b4b1382de49e1220cab0034b155a24d744509ad928a25d69cca57"
},
{
"path": "src/overlay.rs",
"bytes": 72,
"sha256": "104d339ba61626ebf0877f5c942e9f67b3db6261d6c9ae5e4805859508e1bc49"
},
{
"path": "src/pane_grid/axis.rs",
"bytes": 7860,
"sha256": "4e7a842bc60bee64ee86c4d512c0ac034e4c6bf16b1fe0777c16b8a0b5b28751"
},
{
"path": "src/pane_grid/configuration.rs",
"bytes": 609,
"sha256": "0d70a26e2fcafc1f1403e43f6c0790698940c92a0966d4ea4acec216778d65cd"
},
{
"path": "src/pane_grid/content.rs",
"bytes": 11886,
"sha256": "13ec6f9517e0cbd7d2a311ef57b963eff2c5fe6f05264cca29e54d14a0ea8151"
},
{
"path": "src/pane_grid/controls.rs",
"bytes": 1591,
"sha256": "c7eeffce253b8aa0e7853baaf33ab3e926261df7e6b809a566aa17a4e779d560"
},
{
"path": "src/pane_grid/direction.rs",
"bytes": 186,
"sha256": "f79259afb26fb38f4af7efdaea5fc6527b5426fe195ef89f72d16405d0bc3076"
},
{
"path": "src/pane_grid/draggable.rs",
"bytes": 290,
"sha256": "3688ee1a543a281c036d2f4f156a3c529263219b50e3945ef50fa4bb63b02ae3"
},
{
"path": "src/pane_grid/node.rs",
"bytes": 10452,
"sha256": "bcda8a561581849163a3bb75f34d310d6463ff58ea6660e9fa33c8ea22d40c9f"
},
{
"path": "src/pane_grid/pane.rs",
"bytes": 209,
"sha256": "2305c36971b405368a99394ad6b3cd0cf21638d9933f8cbd445fd764a257a4df"
},
{
"path": "src/pane_grid/split.rs",
"bytes": 221,
"sha256": "8be472fc48c37055bbb62395839c599559abc3d70cb7806892f869bd95e90804"
},
{
"path": "src/pane_grid/state.rs",
"bytes": 14109,
"sha256": "a13125af64ea6da1bcb5a13469f4ffdd51106776b27871e10ce3ac037d865b63"
},
{
"path": "src/pane_grid/title_bar.rs",
"bytes": 20398,
"sha256": "7556b48c85811d64e3c11c4ce901c579568f4ec056ff5f1bd5e38066b2f059ce"
},
{
"path": "src/pane_grid.rs",
"bytes": 42574,
"sha256": "4eb5ae3be82237f5193b550d407c1a5a9f099a4d554442dbe8f51b0b72edb213"
},
{
"path": "src/pick_list.rs",
"bytes": 27489,
"sha256": "449fbbc5342c2bed227ec2587111ab43dcecb7382001fbcba2fab2651e54b33b"
},
{
"path": "src/pin.rs",
"bytes": 7207,
"sha256": "f0b0ab2089300c9ceab8f8f3c75cd96409c7a84f3fa26d68645aab3f92247426"
},
{
"path": "src/progress_bar.rs",
"bytes": 8776,
"sha256": "38e9bb585a9b764ff6cb947e17d65259a6a32cf3a69b23844e4c28e6c5a1c3ba"
},
{
"path": "src/qr_code.rs",
"bytes": 12261,
"sha256": "712482a35eab47e0559fafd5452a2b934c5fbc5ae07c14c0c71781713b4dbfcc"
},
{
"path": "src/radio.rs",
"bytes": 15482,
"sha256": "7b0f42744416e07f8af9409c5bae997b07a0653492ccc2e71911bdeed9c747b2"
},
{
"path": "src/responsive.rs",
"bytes": 5358,
"sha256": "2a3ff68573825a1407e3be27adbfb4851c7aae7cd100c7a0b670911bc3a08393"
},
{
"path": "src/row.rs",
"bytes": 16919,
"sha256": "2566e8868bced1a6b7d455c9f1ab61c7025772b27bf843ee806d5c7806fcd44b"
},
{
"path": "src/rule.rs",
"bytes": 8599,
"sha256": "b7b23722d314864d712e21c8ef260d2b747d0ac795bb3263a0a762c38467d6b9"
},
{
"path": "src/scrollable.rs",
"bytes": 80594,
"sha256": "a176ea537bbe717550fe4dc17af31c801d9ec118edbcc44aab59ea1a78d1a7a2"
},
{
"path": "src/sensor.rs",
"bytes": 12155,
"sha256": "cb95d1220fa5663b7a148d6b159081da2200debe6fea982a04f48e27dd072973"
},
{
"path": "src/shader/program.rs",
"bytes": 1761,
"sha256": "4ce3711b2899b1c1d53544ff0c1f823e108542d356d5e374cb7b149c776d6918"
},
{
"path": "src/shader.rs",
"bytes": 5094,
"sha256": "8753516e6a6b422a76632d07c716e047ff9b5e8f32bf9a9463c612c292306382"
},
{
"path": "src/slider.rs",
"bytes": 21695,
"sha256": "778d6a4c37b334cba62ada5f4958e5909a91404961fe5c1104acd9dad12a18d5"
},
{
"path": "src/space.rs",
"bytes": 2436,
"sha256": "40839bf1dfa93e02c9e3d7e2ab5bb41b328b1fe863846f6f1feca11d9885453b"
},
{
"path": "src/stack.rs",
"bytes": 12078,
"sha256": "b12776ae1dd46ba95ca0ca52f6a095be1e0121c51f3180ad7cc202cd55764594"
},
{
"path": "src/svg.rs",
"bytes": 9618,
"sha256": "a8167836a19806f4c2cc633a265953b8c4348f279082d7577b69f817e3166aeb"
},
{
"path": "src/table.rs",
"bytes": 20855,
"sha256": "373f600db363090412e6bfc9c338bd6bc9516e78f7a43fb00f20d5ed21eb625c"
},
{
"path": "src/text/rich.rs",
"bytes": 16952,
"sha256": "7deababcbe5f0294b605822fe2651eb0fee6e9c1bee37883dbc570481df6a0b9"
},
{
"path": "src/text.rs",
"bytes": 902,
"sha256": "a7d125fc7d07ba51e05f86ca97f4ffad5db37ef2e6d4320bbcd8475d99d51ed3"
},
{
"path": "src/text_editor.rs",
"bytes": 48281,
"sha256": "cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1"
},
{
"path": "src/text_input/cursor.rs",
"bytes": 5540,
"sha256": "eeb9241a25110583fd2e169da47834aeb39fd55335ea3b77aa3d1e43016c28d8"
},
{
"path": "src/text_input/editor.rs",
"bytes": 1960,
"sha256": "cd0e0168e9de1ad2599120a4dda8a5a373813a4ffc3a3c035929d18144544408"
},
{
"path": "src/text_input/value.rs",
"bytes": 4403,
"sha256": "03fba71d34f2c31df15431ac7e22f854b655336f86ef854ae34cc1e0bfbcefdd"
},
{
"path": "src/text_input.rs",
"bytes": 61293,
"sha256": "7cd64b0f79c6000f2985cc290642698a814be5dcd7b7a56ae463b13ffe5c3b47"
},
{
"path": "src/themer.rs",
"bytes": 8557,
"sha256": "4b87e866b8f2a4420f30674ad64c605d24e3004f867bf95a1210b5338ed7a81e"
},
{
"path": "src/toggler.rs",
"bytes": 18223,
"sha256": "70f1ef0ee8e2b6c0d7b93095eab1e615783bb81d945b4ff066af025dde8f5dcb"
},
{
"path": "src/tooltip.rs",
"bytes": 17259,
"sha256": "7fcaa3899ad1a946bb1a51d34d344497005aaccdf825f510e98105d1bc40bc63"
},
{
"path": "src/vertical_slider.rs",
"bytes": 17741,
"sha256": "75777345ced463b4a3b059fbe6c59efc952169987154b0597d87ce4da2076e3b"
}
]
-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140" fill="none" version="1.1" viewBox="35 31 179 171"><rect x="42" y="31.001" width="169.9" height="169.9" rx="49.815" fill="url(#paint1_linear)"/><path d="m182.62 65.747-28.136 28.606-6.13-6.0291 28.136-28.606 6.13 6.0291zm-26.344 0.218-42.204 42.909-6.13-6.029 42.204-42.909 6.13 6.0291zm-61.648 23.913c5.3254-5.3831 10.65-10.765 21.569-21.867l6.13 6.0291c-10.927 11.11-16.258 16.498-21.587 21.885-4.4007 4.4488-8.8009 8.8968-16.359 16.573l31.977 8.358 25.968-26.402 6.13 6.0292-25.968 26.402 8.907 31.908 42.138-42.087 6.076 6.083-49.109 49.05-45.837-12.628-13.394-45.646 1.7714-1.801c10.928-11.111 16.258-16.499 21.588-21.886zm28.419 70.99-8.846-31.689-31.831-8.32 9.1945 31.335 31.482 8.674zm47.734-56.517 7.122-7.1221-6.08-6.0797-7.147 7.1474-30.171 30.674 6.13 6.029 30.146-30.649z" clip-rule="evenodd" fill="url(#paint2_linear)" fill-rule="evenodd"/><defs><filter id="filter0_f" x="55" y="47.001" width="144" height="168" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur" stdDeviation="2"/></filter><linearGradient id="paint0_linear" x1="127" x2="127" y1="51.001" y2="211" gradientUnits="userSpaceOnUse"><stop offset=".052083"/><stop stop-opacity=".08" offset="1"/></linearGradient><linearGradient id="paint1_linear" x1="212" x2="57.5" y1="31.001" y2="189" gradientUnits="userSpaceOnUse"><stop stop-color="#00A3FF" offset="0"/><stop stop-color="#30f" offset="1"/></linearGradient><linearGradient id="paint2_linear" x1="86.098" x2="206.01" y1="158.28" y2="35.327" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#fff" offset="1"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-85
View File
@@ -1,85 +0,0 @@
use crate::core::event;
use crate::core::time::Instant;
use crate::core::window;
/// A runtime action that can be performed by some widgets.
#[derive(Debug, Clone)]
pub struct Action<Message> {
message_to_publish: Option<Message>,
redraw_request: window::RedrawRequest,
event_status: event::Status,
}
impl<Message> Action<Message> {
fn new() -> Self {
Self {
message_to_publish: None,
redraw_request: window::RedrawRequest::Wait,
event_status: event::Status::Ignored,
}
}
/// Creates a new "capturing" [`Action`]. A capturing [`Action`]
/// will make other widgets consider it final and prevent further
/// processing.
///
/// Prevents "event bubbling".
pub fn capture() -> Self {
Self {
event_status: event::Status::Captured,
..Self::new()
}
}
/// Creates a new [`Action`] that publishes the given `Message` for
/// the application to handle.
///
/// Publishing a `Message` always produces a redraw.
pub fn publish(message: Message) -> Self {
Self {
message_to_publish: Some(message),
..Self::new()
}
}
/// Creates a new [`Action`] that requests a redraw to happen as
/// soon as possible; without publishing any `Message`.
pub fn request_redraw() -> Self {
Self {
redraw_request: window::RedrawRequest::NextFrame,
..Self::new()
}
}
/// Creates a new [`Action`] that requests a redraw to happen at
/// the given [`Instant`]; without publishing any `Message`.
///
/// This can be useful to efficiently animate content, like a
/// blinking caret on a text input.
pub fn request_redraw_at(at: Instant) -> Self {
Self {
redraw_request: window::RedrawRequest::At(at),
..Self::new()
}
}
/// Marks the [`Action`] as "capturing". See [`Self::capture`].
pub fn and_capture(mut self) -> Self {
self.event_status = event::Status::Captured;
self
}
/// Converts the [`Action`] into its internal parts.
///
/// This method is meant to be used by runtimes, libraries, or internal
/// widget implementations.
pub fn into_inner(
self,
) -> (Option<Message>, window::RedrawRequest, event::Status) {
(
self.message_to_publish,
self.redraw_request,
self.event_status,
)
}
}
-752
View File
@@ -1,752 +0,0 @@
//! Buttons allow your users to perform actions by pressing them.
//!
//! # Example
//! ```no_run
//! # mod iced { pub mod widget { pub use iced_widget::*; } }
//! # pub type State = ();
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
//! use iced::widget::button;
//!
//! #[derive(Clone)]
//! enum Message {
//! ButtonPressed,
//! }
//!
//! fn view(state: &State) -> Element<'_, Message> {
//! button("Press me!").on_press(Message::ButtonPressed).into()
//! }
//! ```
use crate::core::border::{self, Border};
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::theme::palette;
use crate::core::touch;
use crate::core::widget::Operation;
use crate::core::widget::tree::{self, Tree};
use crate::core::window;
use crate::core::{
Background, Clipboard, Color, Element, Event, Layout, Length, Padding,
Rectangle, Shadow, Shell, Size, Theme, Vector, Widget,
};
/// A generic widget that produces a message when pressed.
///
/// # Example
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::widget::button;
///
/// #[derive(Clone)]
/// enum Message {
/// ButtonPressed,
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// button("Press me!").on_press(Message::ButtonPressed).into()
/// }
/// ```
///
/// If a [`Button::on_press`] handler is not set, the resulting [`Button`] will
/// be disabled:
///
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::widget::button;
///
/// #[derive(Clone)]
/// enum Message {
/// ButtonPressed,
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// button("I am disabled!").into()
/// }
/// ```
pub struct Button<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
where
Renderer: crate::core::Renderer,
Theme: Catalog,
{
content: Element<'a, Message, Theme, Renderer>,
on_press: Option<OnPress<'a, Message>>,
width: Length,
height: Length,
padding: Padding,
clip: bool,
class: Theme::Class<'a>,
status: Option<Status>,
}
enum OnPress<'a, Message> {
Direct(Message),
Closure(Box<dyn Fn() -> Message + 'a>),
}
impl<Message: Clone> OnPress<'_, Message> {
fn get(&self) -> Message {
match self {
OnPress::Direct(message) => message.clone(),
OnPress::Closure(f) => f(),
}
}
}
impl<'a, Message, Theme, Renderer> Button<'a, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
Theme: Catalog,
{
/// Creates a new [`Button`] with the given content.
pub fn new(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let content = content.into();
let size = content.as_widget().size_hint();
Button {
content,
on_press: None,
width: size.width.fluid(),
height: size.height.fluid(),
padding: DEFAULT_PADDING,
clip: false,
class: Theme::default(),
status: None,
}
}
/// Sets the width of the [`Button`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Button`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets the [`Padding`] of the [`Button`].
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.padding = padding.into();
self
}
/// Sets the message that will be produced when the [`Button`] is pressed.
///
/// Unless `on_press` is called, the [`Button`] will be disabled.
pub fn on_press(mut self, on_press: Message) -> Self {
self.on_press = Some(OnPress::Direct(on_press));
self
}
/// Sets the message that will be produced when the [`Button`] is pressed.
///
/// This is analogous to [`Button::on_press`], but using a closure to produce
/// the message.
///
/// This closure will only be called when the [`Button`] is actually pressed and,
/// therefore, this method is useful to reduce overhead if creating the resulting
/// message is slow.
pub fn on_press_with(
mut self,
on_press: impl Fn() -> Message + 'a,
) -> Self {
self.on_press = Some(OnPress::Closure(Box::new(on_press)));
self
}
/// Sets the message that will be produced when the [`Button`] is pressed,
/// if `Some`.
///
/// If `None`, the [`Button`] will be disabled.
pub fn on_press_maybe(mut self, on_press: Option<Message>) -> Self {
self.on_press = on_press.map(OnPress::Direct);
self
}
/// Sets whether the contents of the [`Button`] should be clipped on
/// overflow.
pub fn clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
/// Sets the style of the [`Button`].
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
/// Sets the style class of the [`Button`].
#[cfg(feature = "advanced")]
#[must_use]
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.class = class.into();
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct State {
is_pressed: bool,
}
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Button<'a, Message, Theme, Renderer>
where
Message: 'a + Clone,
Renderer: 'a + crate::core::Renderer,
Theme: Catalog,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::padded(
limits,
self.width,
self.height,
self.padding,
|limits| {
self.content.as_widget_mut().layout(
&mut tree.children[0],
renderer,
limits,
)
},
)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout.children().next().unwrap(),
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout.children().next().unwrap(),
cursor,
renderer,
clipboard,
shell,
viewport,
);
if shell.is_event_captured() {
return;
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(touch::Event::FingerPressed { .. }) => {
if self.on_press.is_some() {
let bounds = layout.bounds();
if cursor.is_over(bounds) {
let state = tree.state.downcast_mut::<State>();
state.is_pressed = true;
shell.capture_event();
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| Event::Touch(touch::Event::FingerLifted { .. }) => {
if let Some(on_press) = &self.on_press {
let state = tree.state.downcast_mut::<State>();
if state.is_pressed {
state.is_pressed = false;
let bounds = layout.bounds();
if cursor.is_over(bounds) {
shell.publish(on_press.get());
}
shell.capture_event();
}
}
}
Event::Touch(touch::Event::FingerLost { .. }) => {
let state = tree.state.downcast_mut::<State>();
state.is_pressed = false;
}
_ => {}
}
let current_status = if self.on_press.is_none() {
Status::Disabled
} else if cursor.is_over(layout.bounds()) {
let state = tree.state.downcast_ref::<State>();
if state.is_pressed {
Status::Pressed
} else {
Status::Hovered
}
} else {
Status::Active
};
if let Event::Window(window::Event::RedrawRequested(_now)) = event {
self.status = Some(current_status);
} else if self.status.is_some_and(|status| status != current_status) {
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let bounds = layout.bounds();
let content_layout = layout.children().next().unwrap();
let style =
theme.style(&self.class, self.status.unwrap_or(Status::Disabled));
if style.background.is_some()
|| style.border.width > 0.0
|| style.shadow.color.a > 0.0
{
renderer.fill_quad(
renderer::Quad {
bounds,
border: style.border,
shadow: style.shadow,
snap: style.snap,
},
style
.background
.unwrap_or(Background::Color(Color::TRANSPARENT)),
);
}
let viewport = if self.clip {
bounds.intersection(viewport).unwrap_or(*viewport)
} else {
*viewport
};
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
&renderer::Style {
text_color: style.text_color,
},
content_layout,
cursor,
&viewport,
);
}
fn mouse_interaction(
&self,
_tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let is_mouse_over = cursor.is_over(layout.bounds());
if is_mouse_over && self.on_press.is_some() {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout.children().next().unwrap(),
renderer,
viewport,
translation,
)
}
}
impl<'a, Message, Theme, Renderer> From<Button<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: Catalog + 'a,
Renderer: crate::core::Renderer + 'a,
{
fn from(button: Button<'a, Message, Theme, Renderer>) -> Self {
Self::new(button)
}
}
/// The default [`Padding`] of a [`Button`].
pub const DEFAULT_PADDING: Padding = Padding {
top: 5.0,
bottom: 5.0,
right: 10.0,
left: 10.0,
};
/// The possible status of a [`Button`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// The [`Button`] can be pressed.
Active,
/// The [`Button`] can be pressed and it is being hovered.
Hovered,
/// The [`Button`] is being pressed.
Pressed,
/// The [`Button`] cannot be pressed.
Disabled,
}
/// The style of a button.
///
/// If not specified with [`Button::style`]
/// the theme will provide the style.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Style {
/// The [`Background`] of the button.
pub background: Option<Background>,
/// The text [`Color`] of the button.
pub text_color: Color,
/// The [`Border`] of the button.
pub border: Border,
/// The [`Shadow`] of the button.
pub shadow: Shadow,
/// Whether the button should be snapped to the pixel grid.
pub snap: bool,
}
impl Style {
/// Updates the [`Style`] with the given [`Background`].
pub fn with_background(self, background: impl Into<Background>) -> Self {
Self {
background: Some(background.into()),
..self
}
}
}
impl Default for Style {
fn default() -> Self {
Self {
background: None,
text_color: Color::BLACK,
border: Border::default(),
shadow: Shadow::default(),
snap: cfg!(feature = "crisp"),
}
}
}
/// The theme catalog of a [`Button`].
///
/// All themes that can be used with [`Button`]
/// must implement this trait.
///
/// # Example
/// ```no_run
/// # use iced_widget::core::{Color, Background};
/// # use iced_widget::button::{Catalog, Status, Style};
/// # struct MyTheme;
/// #[derive(Debug, Default)]
/// pub enum ButtonClass {
/// #[default]
/// Primary,
/// Secondary,
/// Danger
/// }
///
/// impl Catalog for MyTheme {
/// type Class<'a> = ButtonClass;
///
/// fn default<'a>() -> Self::Class<'a> {
/// ButtonClass::default()
/// }
///
///
/// fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
/// let mut style = Style::default();
///
/// match class {
/// ButtonClass::Primary => {
/// style.background = Some(Background::Color(Color::from_rgb(0.529, 0.808, 0.921)));
/// },
/// ButtonClass::Secondary => {
/// style.background = Some(Background::Color(Color::WHITE));
/// },
/// ButtonClass::Danger => {
/// style.background = Some(Background::Color(Color::from_rgb(0.941, 0.502, 0.502)));
/// },
/// }
///
/// style
/// }
/// }
/// ```
///
/// Although, in order to use [`Button::style`]
/// with `MyTheme`, [`Catalog::Class`] must implement
/// `From<StyleFn<'_, MyTheme>>`.
pub trait Catalog {
/// The item class of the [`Catalog`].
type Class<'a>;
/// The default class produced by the [`Catalog`].
fn default<'a>() -> Self::Class<'a>;
/// The [`Style`] of a class with the given status.
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
}
/// A styling function for a [`Button`].
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
impl Catalog for Theme {
type Class<'a> = StyleFn<'a, Self>;
fn default<'a>() -> Self::Class<'a> {
Box::new(primary)
}
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
class(self, status)
}
}
/// A primary button; denoting a main action.
pub fn primary(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.primary.base);
match status {
Status::Active | Status::Pressed => base,
Status::Hovered => Style {
background: Some(Background::Color(palette.primary.strong.color)),
..base
},
Status::Disabled => disabled(base),
}
}
/// A secondary button; denoting a complementary action.
pub fn secondary(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.secondary.base);
match status {
Status::Active | Status::Pressed => base,
Status::Hovered => Style {
background: Some(Background::Color(palette.secondary.strong.color)),
..base
},
Status::Disabled => disabled(base),
}
}
/// A success button; denoting a good outcome.
pub fn success(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.success.base);
match status {
Status::Active | Status::Pressed => base,
Status::Hovered => Style {
background: Some(Background::Color(palette.success.strong.color)),
..base
},
Status::Disabled => disabled(base),
}
}
/// A warning button; denoting a risky action.
pub fn warning(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.warning.base);
match status {
Status::Active | Status::Pressed => base,
Status::Hovered => Style {
background: Some(Background::Color(palette.warning.strong.color)),
..base
},
Status::Disabled => disabled(base),
}
}
/// A danger button; denoting a destructive action.
pub fn danger(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.danger.base);
match status {
Status::Active | Status::Pressed => base,
Status::Hovered => Style {
background: Some(Background::Color(palette.danger.strong.color)),
..base
},
Status::Disabled => disabled(base),
}
}
/// A text button; useful for links.
pub fn text(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = Style {
text_color: palette.background.base.text,
..Style::default()
};
match status {
Status::Active | Status::Pressed => base,
Status::Hovered => Style {
text_color: palette.background.base.text.scale_alpha(0.8),
..base
},
Status::Disabled => disabled(base),
}
}
/// A button using background shades.
pub fn background(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.background.base);
match status {
Status::Active => base,
Status::Pressed => Style {
background: Some(Background::Color(
palette.background.strong.color,
)),
..base
},
Status::Hovered => Style {
background: Some(Background::Color(palette.background.weak.color)),
..base
},
Status::Disabled => disabled(base),
}
}
/// A subtle button using weak background shades.
pub fn subtle(theme: &Theme, status: Status) -> Style {
let palette = theme.extended_palette();
let base = styled(palette.background.weakest);
match status {
Status::Active => base,
Status::Pressed => Style {
background: Some(Background::Color(
palette.background.strong.color,
)),
..base
},
Status::Hovered => Style {
background: Some(Background::Color(
palette.background.weaker.color,
)),
..base
},
Status::Disabled => disabled(base),
}
}
fn styled(pair: palette::Pair) -> Style {
Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: border::rounded(2),
..Style::default()
}
}
fn disabled(style: Style) -> Style {
Style {
background: style
.background
.map(|background| background.scale_alpha(0.5)),
text_color: style.text_color.scale_alpha(0.5),
..style
}
}
-327
View File
@@ -1,327 +0,0 @@
//! Canvases can be leveraged to draw interactive 2D graphics.
//!
//! # Example: Drawing a Simple Circle
//! ```no_run
//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
//! # pub type State = ();
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
//! #
//! use iced::mouse;
//! use iced::widget::canvas;
//! use iced::{Color, Rectangle, Renderer, Theme};
//!
//! // First, we define the data we need for drawing
//! #[derive(Debug)]
//! struct Circle {
//! radius: f32,
//! }
//!
//! // Then, we implement the `Program` trait
//! impl<Message> canvas::Program<Message> for Circle {
//! // No internal state
//! type State = ();
//!
//! fn draw(
//! &self,
//! _state: &(),
//! renderer: &Renderer,
//! _theme: &Theme,
//! bounds: Rectangle,
//! _cursor: mouse::Cursor
//! ) -> Vec<canvas::Geometry> {
//! // We prepare a new `Frame`
//! let mut frame = canvas::Frame::new(renderer, bounds.size());
//!
//! // We create a `Path` representing a simple circle
//! let circle = canvas::Path::circle(frame.center(), self.radius);
//!
//! // And fill it with some color
//! frame.fill(&circle, Color::BLACK);
//!
//! // Then, we produce the geometry
//! vec![frame.into_geometry()]
//! }
//! }
//!
//! // Finally, we simply use our `Circle` to create the `Canvas`!
//! fn view<'a, Message: 'a>(_state: &'a State) -> Element<'a, Message> {
//! canvas(Circle { radius: 50.0 }).into()
//! }
//! ```
mod program;
pub use program::Program;
pub use crate::Action;
pub use crate::core::event::Event;
pub use crate::graphics::cache::Group;
pub use crate::graphics::geometry::{
Fill, Gradient, Image, LineCap, LineDash, LineJoin, Path, Stroke, Style,
Text, fill, gradient, path, stroke,
};
use crate::core::event;
use crate::core::layout::{self, Layout};
use crate::core::mouse;
use crate::core::renderer;
use crate::core::widget::tree::{self, Tree};
use crate::core::window;
use crate::core::{
Clipboard, Element, Length, Rectangle, Shell, Size, Vector, Widget,
};
use crate::graphics::geometry;
use std::marker::PhantomData;
/// A simple cache that stores generated [`Geometry`] to avoid recomputation.
///
/// A [`Cache`] will not redraw its geometry unless the dimensions of its layer
/// change or it is explicitly cleared.
pub type Cache<Renderer = crate::Renderer> = geometry::Cache<Renderer>;
/// The geometry supported by a renderer.
pub type Geometry<Renderer = crate::Renderer> =
<Renderer as geometry::Renderer>::Geometry;
/// The frame supported by a renderer.
pub type Frame<Renderer = crate::Renderer> = geometry::Frame<Renderer>;
/// A widget capable of drawing 2D graphics.
///
/// # Example: Drawing a Simple Circle
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// #
/// use iced::mouse;
/// use iced::widget::canvas;
/// use iced::{Color, Rectangle, Renderer, Theme};
///
/// // First, we define the data we need for drawing
/// #[derive(Debug)]
/// struct Circle {
/// radius: f32,
/// }
///
/// // Then, we implement the `Program` trait
/// impl<Message> canvas::Program<Message> for Circle {
/// // No internal state
/// type State = ();
///
/// fn draw(
/// &self,
/// _state: &(),
/// renderer: &Renderer,
/// _theme: &Theme,
/// bounds: Rectangle,
/// _cursor: mouse::Cursor
/// ) -> Vec<canvas::Geometry> {
/// // We prepare a new `Frame`
/// let mut frame = canvas::Frame::new(renderer, bounds.size());
///
/// // We create a `Path` representing a simple circle
/// let circle = canvas::Path::circle(frame.center(), self.radius);
///
/// // And fill it with some color
/// frame.fill(&circle, Color::BLACK);
///
/// // Then, we produce the geometry
/// vec![frame.into_geometry()]
/// }
/// }
///
/// // Finally, we simply use our `Circle` to create the `Canvas`!
/// fn view<'a, Message: 'a>(_state: &'a State) -> Element<'a, Message> {
/// canvas(Circle { radius: 50.0 }).into()
/// }
/// ```
#[derive(Debug)]
pub struct Canvas<P, Message, Theme = crate::Theme, Renderer = crate::Renderer>
where
Renderer: geometry::Renderer,
P: Program<Message, Theme, Renderer>,
{
width: Length,
height: Length,
program: P,
message_: PhantomData<Message>,
theme_: PhantomData<Theme>,
renderer_: PhantomData<Renderer>,
last_mouse_interaction: Option<mouse::Interaction>,
}
impl<P, Message, Theme, Renderer> Canvas<P, Message, Theme, Renderer>
where
P: Program<Message, Theme, Renderer>,
Renderer: geometry::Renderer,
{
const DEFAULT_SIZE: f32 = 100.0;
/// Creates a new [`Canvas`].
pub fn new(program: P) -> Self {
Canvas {
width: Length::Fixed(Self::DEFAULT_SIZE),
height: Length::Fixed(Self::DEFAULT_SIZE),
program,
message_: PhantomData,
theme_: PhantomData,
renderer_: PhantomData,
last_mouse_interaction: None,
}
}
/// Sets the width of the [`Canvas`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Canvas`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
}
impl<P, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Canvas<P, Message, Theme, Renderer>
where
Renderer: geometry::Renderer,
P: Program<Message, Theme, Renderer>,
{
fn tag(&self) -> tree::Tag {
struct Tag<T>(T);
tree::Tag::of::<Tag<P::State>>()
}
fn state(&self) -> tree::State {
tree::State::new(P::State::default())
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
_tree: &mut Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(limits, self.width, self.height)
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let bounds = layout.bounds();
let state = tree.state.downcast_mut::<P::State>();
let is_redraw_request = matches!(
event,
Event::Window(window::Event::RedrawRequested(_now)),
);
if let Some(action) = self.program.update(state, event, bounds, cursor)
{
let (message, redraw_request, event_status) = action.into_inner();
shell.request_redraw_at(redraw_request);
if let Some(message) = message {
shell.publish(message);
}
if event_status == event::Status::Captured {
shell.capture_event();
}
}
if shell.redraw_request() != window::RedrawRequest::NextFrame {
let mouse_interaction = self
.mouse_interaction(tree, layout, cursor, viewport, renderer);
if is_redraw_request {
self.last_mouse_interaction = Some(mouse_interaction);
} else if self.last_mouse_interaction.is_some_and(
|last_mouse_interaction| {
last_mouse_interaction != mouse_interaction
},
) {
shell.request_redraw();
}
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let bounds = layout.bounds();
let state = tree.state.downcast_ref::<P::State>();
self.program.mouse_interaction(state, bounds, cursor)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
if bounds.width < 1.0 || bounds.height < 1.0 {
return;
}
let state = tree.state.downcast_ref::<P::State>();
renderer.with_translation(
Vector::new(bounds.x, bounds.y),
|renderer| {
let layers =
self.program.draw(state, renderer, theme, bounds, cursor);
for layer in layers {
renderer.draw_geometry(layer);
}
},
);
}
}
impl<'a, P, Message, Theme, Renderer> From<Canvas<P, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: 'a + geometry::Renderer,
P: 'a + Program<Message, Theme, Renderer>,
{
fn from(
canvas: Canvas<P, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(canvas)
}
}
-111
View File
@@ -1,111 +0,0 @@
use crate::Action;
use crate::canvas::mouse;
use crate::canvas::{Event, Geometry};
use crate::core::Rectangle;
use crate::graphics::geometry;
/// The state and logic of a [`Canvas`].
///
/// A [`Program`] can mutate internal state and produce messages for an
/// application.
///
/// [`Canvas`]: crate::Canvas
pub trait Program<Message, Theme = crate::Theme, Renderer = crate::Renderer>
where
Renderer: geometry::Renderer,
{
/// The internal state mutated by the [`Program`].
type State: Default + 'static;
/// Updates the [`State`](Self::State) of the [`Program`].
///
/// When a [`Program`] is used in a [`Canvas`], the runtime will call this
/// method for each [`Event`].
///
/// This method can optionally return an [`Action`] to either notify an
/// application of any meaningful interactions, capture the event, or
/// request a redraw.
///
/// By default, this method does and returns nothing.
///
/// [`Canvas`]: crate::Canvas
fn update(
&self,
_state: &mut Self::State,
_event: &Event,
_bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Option<Action<Message>> {
None
}
/// Draws the state of the [`Program`], producing a bunch of [`Geometry`].
///
/// [`Geometry`] can be easily generated with a [`Frame`] or stored in a
/// [`Cache`].
///
/// [`Geometry`]: crate::canvas::Geometry
/// [`Frame`]: crate::canvas::Frame
/// [`Cache`]: crate::canvas::Cache
fn draw(
&self,
state: &Self::State,
renderer: &Renderer,
theme: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<Geometry<Renderer>>;
/// Returns the current mouse interaction of the [`Program`].
///
/// The interaction returned will be in effect even if the cursor position
/// is out of bounds of the program's [`Canvas`].
///
/// [`Canvas`]: crate::Canvas
fn mouse_interaction(
&self,
_state: &Self::State,
_bounds: Rectangle,
_cursor: mouse::Cursor,
) -> mouse::Interaction {
mouse::Interaction::default()
}
}
impl<Message, Theme, Renderer, T> Program<Message, Theme, Renderer> for &T
where
Renderer: geometry::Renderer,
T: Program<Message, Theme, Renderer>,
{
type State = T::State;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
T::update(self, state, event, bounds, cursor)
}
fn draw(
&self,
state: &Self::State,
renderer: &Renderer,
theme: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<Geometry<Renderer>> {
T::draw(self, state, renderer, theme, bounds, cursor)
}
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
T::mouse_interaction(self, state, bounds, cursor)
}
}
-707
View File
@@ -1,707 +0,0 @@
//! Checkboxes can be used to let users make binary choices.
//!
//! # Example
//! ```no_run
//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
//! #
//! use iced::widget::checkbox;
//!
//! struct State {
//! is_checked: bool,
//! }
//!
//! enum Message {
//! CheckboxToggled(bool),
//! }
//!
//! fn view(state: &State) -> Element<'_, Message> {
//! checkbox(state.is_checked)
//! .label("Toggle me!")
//! .on_toggle(Message::CheckboxToggled)
//! .into()
//! }
//!
//! fn update(state: &mut State, message: Message) {
//! match message {
//! Message::CheckboxToggled(is_checked) => {
//! state.is_checked = is_checked;
//! }
//! }
//! }
//! ```
//! ![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
@@ -1,617 +0,0 @@
//! Distribute content vertically.
use crate::core::alignment::{self, Alignment};
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget::{Operation, Tree};
use crate::core::{
Clipboard, Element, Event, Layout, Length, Padding, Pixels, Rectangle,
Shell, Size, Vector, Widget,
};
/// A container that distributes its contents vertically.
///
/// # Example
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::widget::{button, column};
///
/// #[derive(Debug, Clone)]
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// column![
/// "I am on top!",
/// button("I am in the center!"),
/// "I am below.",
/// ].into()
/// }
/// ```
pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
{
spacing: f32,
padding: Padding,
width: Length,
height: Length,
max_width: f32,
align: Alignment,
clip: bool,
children: Vec<Element<'a, Message, Theme, Renderer>>,
}
impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
{
/// Creates an empty [`Column`].
pub fn new() -> Self {
Self::from_vec(Vec::new())
}
/// Creates a [`Column`] with the given capacity.
pub fn with_capacity(capacity: usize) -> Self {
Self::from_vec(Vec::with_capacity(capacity))
}
/// Creates a [`Column`] with the given elements.
pub fn with_children(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Self {
let iterator = children.into_iter();
Self::with_capacity(iterator.size_hint().0).extend(iterator)
}
/// Creates a [`Column`] from an already allocated [`Vec`].
///
/// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means
/// it won't automatically adapt to the sizing strategy of its contents.
///
/// If any of the children have a [`Length::Fill`] strategy, you will need to
/// call [`Column::width`] or [`Column::height`] accordingly.
pub fn from_vec(
children: Vec<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
spacing: 0.0,
padding: Padding::ZERO,
width: Length::Shrink,
height: Length::Shrink,
max_width: f32::INFINITY,
align: Alignment::Start,
clip: false,
children,
}
}
/// Sets the vertical spacing _between_ elements.
///
/// Custom margins per element do not exist in iced. You should use this
/// method instead! While less flexible, it helps you keep spacing between
/// elements consistent.
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
self.spacing = amount.into().0;
self
}
/// Sets the [`Padding`] of the [`Column`].
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.padding = padding.into();
self
}
/// Sets the width of the [`Column`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Column`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets the maximum width of the [`Column`].
pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self {
self.max_width = max_width.into().0;
self
}
/// Sets the horizontal alignment of the contents of the [`Column`] .
pub fn align_x(mut self, align: impl Into<alignment::Horizontal>) -> Self {
self.align = Alignment::from(align.into());
self
}
/// Sets whether the contents of the [`Column`] should be clipped on
/// overflow.
pub fn clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
/// Adds an element to the [`Column`].
pub fn push(
mut self,
child: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let child = child.into();
let child_size = child.as_widget().size_hint();
if !child_size.is_void() {
self.width = self.width.enclose(child_size.width);
self.height = self.height.enclose(child_size.height);
self.children.push(child);
}
self
}
/// Extends the [`Column`] with the given children.
pub fn extend(
self,
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Self {
children.into_iter().fold(self, Self::push)
}
/// Turns the [`Column`] into a [`Wrapping`] column.
///
/// The original alignment of the [`Column`] is preserved per column wrapped.
pub fn wrap(self) -> Wrapping<'a, Message, Theme, Renderer> {
Wrapping {
column: self,
horizontal_spacing: None,
align_y: alignment::Vertical::Top,
}
}
}
impl<Message, Renderer> Default for Column<'_, Message, Renderer>
where
Renderer: crate::core::Renderer,
{
fn default() -> Self {
Self::new()
}
}
impl<'a, Message, Theme, Renderer: crate::core::Renderer>
FromIterator<Element<'a, Message, Theme, Renderer>>
for Column<'a, Message, Theme, Renderer>
{
fn from_iter<
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
>(
iter: T,
) -> Self {
Self::with_children(iter)
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Column<'_, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
{
fn children(&self) -> Vec<Tree> {
self.children.iter().map(Tree::new).collect()
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(&self.children);
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let limits = limits.max_width(self.max_width);
layout::flex::resolve(
layout::flex::Axis::Vertical,
renderer,
&limits,
self.width,
self.height,
self.padding,
self.spacing,
self.align,
&mut self.children,
&mut tree.children,
)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
.for_each(|((child, state), layout)| {
child
.as_widget_mut()
.operate(state, layout, renderer, operation);
});
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
for ((child, tree), layout) in self
.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
{
child.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell,
viewport,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.map(|((child, tree), layout)| {
child
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
})
.max()
.unwrap_or_default()
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
if let Some(clipped_viewport) = layout.bounds().intersection(viewport) {
let viewport = if self.clip {
&clipped_viewport
} else {
viewport
};
for ((child, tree), layout) in self
.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.filter(|(_, layout)| layout.bounds().intersects(viewport))
{
child.as_widget().draw(
tree, renderer, theme, style, layout, cursor, viewport,
);
}
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
overlay::from_children(
&mut self.children,
tree,
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message, Theme, Renderer> From<Column<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: crate::core::Renderer + 'a,
{
fn from(column: Column<'a, Message, Theme, Renderer>) -> Self {
Self::new(column)
}
}
/// A [`Column`] that wraps its contents.
///
/// Create a [`Column`] first, and then call [`Column::wrap`] to
/// obtain a [`Column`] that wraps its contents.
///
/// The original alignment of the [`Column`] is preserved per column wrapped.
#[allow(missing_debug_implementations)]
pub struct Wrapping<
'a,
Message,
Theme = crate::Theme,
Renderer = crate::Renderer,
> {
column: Column<'a, Message, Theme, Renderer>,
horizontal_spacing: Option<f32>,
align_y: alignment::Vertical,
}
impl<Message, Theme, Renderer> Wrapping<'_, Message, Theme, Renderer> {
/// Sets the horizontal spacing _between_ columns.
pub fn horizontal_spacing(mut self, amount: impl Into<Pixels>) -> Self {
self.horizontal_spacing = Some(amount.into().0);
self
}
/// Sets the vertical alignment of the wrapping [`Column`].
pub fn align_x(mut self, align_y: impl Into<alignment::Vertical>) -> Self {
self.align_y = align_y.into();
self
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Wrapping<'_, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
{
fn children(&self) -> Vec<Tree> {
self.column.children()
}
fn diff(&self, tree: &mut Tree) {
self.column.diff(tree);
}
fn size(&self) -> Size<Length> {
self.column.size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let limits = limits
.width(self.column.width)
.height(self.column.height)
.shrink(self.column.padding);
let child_limits = limits.loose();
let spacing = self.column.spacing;
let horizontal_spacing = self.horizontal_spacing.unwrap_or(spacing);
let max_height = limits.max().height;
let mut children: Vec<layout::Node> = Vec::new();
let mut intrinsic_size = Size::ZERO;
let mut column_start = 0;
let mut column_width = 0.0;
let mut x = 0.0;
let mut y = 0.0;
let align_factor = match self.column.align {
Alignment::Start => 0.0,
Alignment::Center => 2.0,
Alignment::End => 1.0,
};
let align_x = |column_start: std::ops::Range<usize>,
column_width: f32,
children: &mut Vec<layout::Node>| {
if align_factor != 0.0 {
for node in &mut children[column_start] {
let width = node.size().width;
node.translate_mut(Vector::new(
(column_width - width) / align_factor,
0.0,
));
}
}
};
for (i, child) in self.column.children.iter_mut().enumerate() {
let node = child.as_widget_mut().layout(
&mut tree.children[i],
renderer,
&child_limits,
);
let child_size = node.size();
if y != 0.0 && y + child_size.height > max_height {
intrinsic_size.height = intrinsic_size.height.max(y - spacing);
align_x(column_start..i, column_width, &mut children);
x += column_width + horizontal_spacing;
y = 0.0;
column_start = i;
column_width = 0.0;
}
column_width = column_width.max(child_size.width);
children.push(node.move_to((
x + self.column.padding.left,
y + self.column.padding.top,
)));
y += child_size.height + spacing;
}
if y != 0.0 {
intrinsic_size.height = intrinsic_size.height.max(y - spacing);
}
intrinsic_size.width = x + column_width;
align_x(column_start..children.len(), column_width, &mut children);
let align_factor = match self.align_y {
alignment::Vertical::Top => 0.0,
alignment::Vertical::Center => 2.0,
alignment::Vertical::Bottom => 1.0,
};
if align_factor != 0.0 {
let total_height = intrinsic_size.height;
let mut column_start = 0;
for i in 0..children.len() {
let bounds = children[i].bounds();
let column_height = bounds.y + bounds.height;
let next_y = children
.get(i + 1)
.map(|node| node.bounds().y)
.unwrap_or_default();
if next_y == 0.0 {
let translation = Vector::new(
0.0,
(total_height - column_height) / align_factor,
);
for node in &mut children[column_start..=i] {
node.translate_mut(translation);
}
column_start = i + 1;
}
}
}
let size = limits.resolve(
self.column.width,
self.column.height,
intrinsic_size,
);
layout::Node::with_children(size.expand(self.column.padding), children)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.column.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.column.update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.column
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.column
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.column
.overlay(tree, layout, renderer, viewport, translation)
}
}
impl<'a, Message, Theme, Renderer> From<Wrapping<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: crate::core::Renderer + 'a,
{
fn from(column: Wrapping<'a, Message, Theme, Renderer>) -> Self {
Self::new(column)
}
}
File diff suppressed because it is too large Load Diff
-660
View File
@@ -1,660 +0,0 @@
//! Containers let you align a widget inside their boundaries.
//!
//! # Example
//! ```no_run
//! # mod iced { pub mod widget { pub use iced_widget::*; } }
//! # pub type State = ();
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
//! use iced::widget::container;
//!
//! enum Message {
//! // ...
//! }
//!
//! fn view(state: &State) -> Element<'_, Message> {
//! container("This text is centered inside a rounded box!")
//! .padding(10)
//! .center(800)
//! .style(container::rounded_box)
//! .into()
//! }
//! ```
use crate::core::alignment::{self, Alignment};
use crate::core::border::{self, Border};
use crate::core::gradient::{self, Gradient};
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::theme;
use crate::core::widget::tree::{self, Tree};
use crate::core::widget::{self, Operation};
use crate::core::{
self, Background, Clipboard, Color, Element, Event, Layout, Length,
Padding, Pixels, Rectangle, Shadow, Shell, Size, Theme, Vector, Widget,
color,
};
/// A widget that aligns its contents inside of its boundaries.
///
/// # Example
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::widget::container;
///
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// container("This text is centered inside a rounded box!")
/// .padding(10)
/// .center(800)
/// .style(container::rounded_box)
/// .into()
/// }
/// ```
pub struct Container<
'a,
Message,
Theme = crate::Theme,
Renderer = crate::Renderer,
> where
Theme: Catalog,
Renderer: core::Renderer,
{
id: Option<widget::Id>,
padding: Padding,
width: Length,
height: Length,
max_width: f32,
max_height: f32,
horizontal_alignment: alignment::Horizontal,
vertical_alignment: alignment::Vertical,
clip: bool,
content: Element<'a, Message, Theme, Renderer>,
class: Theme::Class<'a>,
}
impl<'a, Message, Theme, Renderer> Container<'a, Message, Theme, Renderer>
where
Theme: Catalog,
Renderer: core::Renderer,
{
/// Creates a [`Container`] with the given content.
pub fn new(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let content = content.into();
let size = content.as_widget().size_hint();
Container {
id: None,
padding: Padding::ZERO,
width: size.width.fluid(),
height: size.height.fluid(),
max_width: f32::INFINITY,
max_height: f32::INFINITY,
horizontal_alignment: alignment::Horizontal::Left,
vertical_alignment: alignment::Vertical::Top,
clip: false,
class: Theme::default(),
content,
}
}
/// Sets the [`widget::Id`] of the [`Container`].
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
self.id = Some(id.into());
self
}
/// Sets the [`Padding`] of the [`Container`].
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.padding = padding.into();
self
}
/// Sets the width of the [`Container`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Container`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets the maximum width of the [`Container`].
pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self {
self.max_width = max_width.into().0;
self
}
/// Sets the maximum height of the [`Container`].
pub fn max_height(mut self, max_height: impl Into<Pixels>) -> Self {
self.max_height = max_height.into().0;
self
}
/// Sets the width of the [`Container`] and centers its contents horizontally.
pub fn center_x(self, width: impl Into<Length>) -> Self {
self.width(width).align_x(alignment::Horizontal::Center)
}
/// Sets the height of the [`Container`] and centers its contents vertically.
pub fn center_y(self, height: impl Into<Length>) -> Self {
self.height(height).align_y(alignment::Vertical::Center)
}
/// Sets the width and height of the [`Container`] and centers its contents in
/// both the horizontal and vertical axes.
///
/// This is equivalent to chaining [`center_x`] and [`center_y`].
///
/// [`center_x`]: Self::center_x
/// [`center_y`]: Self::center_y
pub fn center(self, length: impl Into<Length>) -> Self {
let length = length.into();
self.center_x(length).center_y(length)
}
/// Sets the width of the [`Container`] and aligns its contents to the left.
pub fn align_left(self, width: impl Into<Length>) -> Self {
self.width(width).align_x(alignment::Horizontal::Left)
}
/// Sets the width of the [`Container`] and aligns its contents to the right.
pub fn align_right(self, width: impl Into<Length>) -> Self {
self.width(width).align_x(alignment::Horizontal::Right)
}
/// Sets the height of the [`Container`] and aligns its contents to the top.
pub fn align_top(self, height: impl Into<Length>) -> Self {
self.height(height).align_y(alignment::Vertical::Top)
}
/// Sets the height of the [`Container`] and aligns its contents to the bottom.
pub fn align_bottom(self, height: impl Into<Length>) -> Self {
self.height(height).align_y(alignment::Vertical::Bottom)
}
/// Sets the content alignment for the horizontal axis of the [`Container`].
pub fn align_x(
mut self,
alignment: impl Into<alignment::Horizontal>,
) -> Self {
self.horizontal_alignment = alignment.into();
self
}
/// Sets the content alignment for the vertical axis of the [`Container`].
pub fn align_y(
mut self,
alignment: impl Into<alignment::Vertical>,
) -> Self {
self.vertical_alignment = alignment.into();
self
}
/// Sets whether the contents of the [`Container`] should be clipped on
/// overflow.
pub fn clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
/// Sets the style of the [`Container`].
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
/// Sets the style class of the [`Container`].
#[must_use]
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.class = class.into();
self
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Container<'_, Message, Theme, Renderer>
where
Theme: Catalog,
Renderer: core::Renderer,
{
fn tag(&self) -> tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut Tree) {
self.content.as_widget().diff(tree);
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout(
limits,
self.width,
self.height,
self.max_width,
self.max_height,
self.padding,
self.horizontal_alignment,
self.vertical_alignment,
|limits| {
self.content.as_widget_mut().layout(tree, renderer, limits)
},
)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(self.id.as_ref(), layout.bounds());
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
tree,
layout.children().next().unwrap(),
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
tree,
event,
layout.children().next().unwrap(),
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
tree,
layout.children().next().unwrap(),
cursor,
viewport,
renderer,
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let bounds = layout.bounds();
let style = theme.style(&self.class);
if let Some(clipped_viewport) = bounds.intersection(viewport) {
draw_background(renderer, &style, bounds);
self.content.as_widget().draw(
tree,
renderer,
theme,
&renderer::Style {
text_color: style
.text_color
.unwrap_or(renderer_style.text_color),
},
layout.children().next().unwrap(),
cursor,
if self.clip {
&clipped_viewport
} else {
viewport
},
);
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
tree,
layout.children().next().unwrap(),
renderer,
viewport,
translation,
)
}
}
impl<'a, Message, Theme, Renderer> From<Container<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: Catalog + 'a,
Renderer: core::Renderer + 'a,
{
fn from(
container: Container<'a, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(container)
}
}
/// Computes the layout of a [`Container`].
pub fn layout(
limits: &layout::Limits,
width: Length,
height: Length,
max_width: f32,
max_height: f32,
padding: Padding,
horizontal_alignment: alignment::Horizontal,
vertical_alignment: alignment::Vertical,
layout_content: impl FnOnce(&layout::Limits) -> layout::Node,
) -> layout::Node {
layout::positioned(
&limits.max_width(max_width).max_height(max_height),
width,
height,
padding,
|limits| layout_content(&limits.loose()),
|content, size| {
content.align(
Alignment::from(horizontal_alignment),
Alignment::from(vertical_alignment),
size,
)
},
)
}
/// Draws the background of a [`Container`] given its [`Style`] and its `bounds`.
pub fn draw_background<Renderer>(
renderer: &mut Renderer,
style: &Style,
bounds: Rectangle,
) where
Renderer: core::Renderer,
{
if style.background.is_some()
|| style.border.width > 0.0
|| style.shadow.color.a > 0.0
{
renderer.fill_quad(
renderer::Quad {
bounds,
border: style.border,
shadow: style.shadow,
snap: style.snap,
},
style
.background
.unwrap_or(Background::Color(Color::TRANSPARENT)),
);
}
}
/// The appearance of a container.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Style {
/// The text [`Color`] of the container.
pub text_color: Option<Color>,
/// The [`Background`] of the container.
pub background: Option<Background>,
/// The [`Border`] of the container.
pub border: Border,
/// The [`Shadow`] of the container.
pub shadow: Shadow,
/// Whether the container should be snapped to the pixel grid.
pub snap: bool,
}
impl Default for Style {
fn default() -> Self {
Self {
text_color: None,
background: None,
border: Border::default(),
shadow: Shadow::default(),
snap: cfg!(feature = "crisp"),
}
}
}
impl Style {
/// Updates the text color of the [`Style`].
pub fn color(self, color: impl Into<Color>) -> Self {
Self {
text_color: Some(color.into()),
..self
}
}
/// Updates the border of the [`Style`].
pub fn border(self, border: impl Into<Border>) -> Self {
Self {
border: border.into(),
..self
}
}
/// Updates the background of the [`Style`].
pub fn background(self, background: impl Into<Background>) -> Self {
Self {
background: Some(background.into()),
..self
}
}
/// Updates the shadow of the [`Style`].
pub fn shadow(self, shadow: impl Into<Shadow>) -> Self {
Self {
shadow: shadow.into(),
..self
}
}
}
impl From<Color> for Style {
fn from(color: Color) -> Self {
Self::default().background(color)
}
}
impl From<Gradient> for Style {
fn from(gradient: Gradient) -> Self {
Self::default().background(gradient)
}
}
impl From<gradient::Linear> for Style {
fn from(gradient: gradient::Linear) -> Self {
Self::default().background(gradient)
}
}
/// The theme catalog of a [`Container`].
pub trait Catalog {
/// The item class of the [`Catalog`].
type Class<'a>;
/// The default class produced by the [`Catalog`].
fn default<'a>() -> Self::Class<'a>;
/// The [`Style`] of a class with the given status.
fn style(&self, class: &Self::Class<'_>) -> Style;
}
/// A styling function for a [`Container`].
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
impl<Theme> From<Style> for StyleFn<'_, Theme> {
fn from(style: Style) -> Self {
Box::new(move |_theme| style)
}
}
impl Catalog for Theme {
type Class<'a> = StyleFn<'a, Self>;
fn default<'a>() -> Self::Class<'a> {
Box::new(transparent)
}
fn style(&self, class: &Self::Class<'_>) -> Style {
class(self)
}
}
/// A transparent [`Container`].
pub fn transparent<Theme>(_theme: &Theme) -> Style {
Style::default()
}
/// A [`Container`] with the given [`Background`].
pub fn background(background: impl Into<Background>) -> Style {
Style::default().background(background)
}
/// A rounded [`Container`] with a background.
pub fn rounded_box(theme: &Theme) -> Style {
let palette = theme.extended_palette();
Style {
background: Some(palette.background.weak.color.into()),
text_color: Some(palette.background.weak.text),
border: border::rounded(2),
..Style::default()
}
}
/// A bordered [`Container`] with a background.
pub fn bordered_box(theme: &Theme) -> Style {
let palette = theme.extended_palette();
Style {
background: Some(palette.background.weakest.color.into()),
text_color: Some(palette.background.weakest.text),
border: Border {
width: 1.0,
radius: 5.0.into(),
color: palette.background.weak.color,
},
..Style::default()
}
}
/// A [`Container`] with a dark background and white text.
pub fn dark(_theme: &Theme) -> Style {
style(theme::palette::Pair {
color: color!(0x111111),
text: Color::WHITE,
})
}
/// A [`Container`] with a primary background color.
pub fn primary(theme: &Theme) -> Style {
let palette = theme.extended_palette();
style(palette.primary.base)
}
/// A [`Container`] with a secondary background color.
pub fn secondary(theme: &Theme) -> Style {
let palette = theme.extended_palette();
style(palette.secondary.base)
}
/// A [`Container`] with a success background color.
pub fn success(theme: &Theme) -> Style {
let palette = theme.extended_palette();
style(palette.success.base)
}
/// A [`Container`] with a warning background color.
pub fn warning(theme: &Theme) -> Style {
let palette = theme.extended_palette();
style(palette.warning.base)
}
/// A [`Container`] with a danger background color.
pub fn danger(theme: &Theme) -> Style {
let palette = theme.extended_palette();
style(palette.danger.base)
}
fn style(pair: theme::palette::Pair) -> Style {
Style {
background: Some(pair.color.into()),
text_color: Some(pair.text),
border: border::rounded(2),
..Style::default()
}
}
-432
View File
@@ -1,432 +0,0 @@
//! Make elements float!
use crate::core;
use crate::core::border;
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget;
use crate::core::widget::tree;
use crate::core::{
Clipboard, Element, Event, Layout, Length, Rectangle, Shadow, Shell, Size,
Transformation, Vector, Widget,
};
/// A widget that can make its contents float over other widgets.
pub struct Float<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
where
Theme: Catalog,
{
content: Element<'a, Message, Theme, Renderer>,
scale: f32,
translate: Option<Box<dyn Fn(Rectangle, Rectangle) -> Vector + 'a>>,
class: Theme::Class<'a>,
}
impl<'a, Message, Theme, Renderer> Float<'a, Message, Theme, Renderer>
where
Theme: Catalog,
{
/// Creates a new [`Float`] widget with the given content.
pub fn new(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
content: content.into(),
scale: 1.0,
translate: None,
class: Theme::default(),
}
}
/// Sets the scale to be applied to the contents of the [`Float`].
pub fn scale(mut self, scale: f32) -> Self {
self.scale = scale;
self
}
/// Sets the translation logic to be applied to the contents of the [`Float`].
///
/// The logic takes the original (non-scaled) bounds of the contents and the
/// viewport bounds. These bounds can be useful to ensure the floating elements
/// always stay on screen.
pub fn translate(
mut self,
translate: impl Fn(Rectangle, Rectangle) -> Vector + 'a,
) -> Self {
self.translate = Some(Box::new(translate));
self
}
/// Sets the style of the [`Float`].
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
/// Sets the style class of the [`Float`].
#[cfg(feature = "advanced")]
#[must_use]
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.class = class.into();
self
}
fn is_floating(&self, bounds: Rectangle, viewport: Rectangle) -> bool {
self.scale > 1.0
|| self.translate.as_ref().is_some_and(|translate| {
translate(bounds, viewport) != Vector::ZERO
})
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Float<'_, Message, Theme, Renderer>
where
Theme: Catalog,
Renderer: core::Renderer,
{
fn tag(&self) -> tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<tree::Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut widget::Tree) {
self.content.as_widget().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if self.is_floating(layout.bounds(), *viewport) {
return;
}
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
if self.is_floating(layout.bounds(), *viewport) {
return;
}
{
let style = theme.style(&self.class);
if style.shadow.color.a > 0.0 {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds().shrink(1.0),
shadow: style.shadow,
border: border::rounded(style.shadow_border_radius),
snap: false,
},
style.shadow.color,
);
}
}
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
if self.is_floating(layout.bounds(), *viewport) {
return mouse::Interaction::None;
}
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
state: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
offset: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let bounds = layout.bounds();
let translation = self
.translate
.as_ref()
.map(|translate| translate(bounds + offset, *viewport))
.unwrap_or(Vector::ZERO);
if self.scale > 1.0 || translation != Vector::ZERO {
let translation = translation + offset;
let transformation = Transformation::translate(
bounds.x + bounds.width / 2.0 + translation.x,
bounds.y + bounds.height / 2.0 + translation.y,
) * Transformation::scale(self.scale)
* Transformation::translate(
-bounds.x - bounds.width / 2.0,
-bounds.y - bounds.height / 2.0,
);
Some(overlay::Element::new(Box::new(Overlay {
float: self,
state,
layout,
viewport: *viewport,
transformation,
})))
} else {
self.content
.as_widget_mut()
.overlay(state, layout, renderer, viewport, offset)
}
}
}
impl<'a, Message, Theme, Renderer> From<Float<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: Catalog + 'a,
Renderer: core::Renderer + 'a,
{
fn from(float: Float<'a, Message, Theme, Renderer>) -> Self {
Element::new(float)
}
}
struct Overlay<'a, 'b, Message, Theme, Renderer>
where
Theme: Catalog,
{
float: &'a mut Float<'b, Message, Theme, Renderer>,
state: &'a mut widget::Tree,
layout: Layout<'a>,
viewport: Rectangle,
transformation: Transformation,
}
impl<Message, Theme, Renderer> core::Overlay<Message, Theme, Renderer>
for Overlay<'_, '_, Message, Theme, Renderer>
where
Theme: Catalog,
Renderer: core::Renderer,
{
fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node {
let bounds = self.layout.bounds() * self.transformation;
layout::Node::new(bounds.size()).move_to(bounds.position())
}
fn update(
&mut self,
event: &Event,
_layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let inverse = self.transformation.inverse();
self.float.content.as_widget_mut().update(
self.state,
event,
self.layout,
cursor * inverse,
renderer,
clipboard,
shell,
&(self.viewport * inverse),
);
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
_layout: Layout<'_>,
cursor: mouse::Cursor,
) {
let bounds = self.layout.bounds();
let inverse = self.transformation.inverse();
renderer.with_layer(self.viewport, |renderer| {
renderer.with_transformation(self.transformation, |renderer| {
{
let style = theme.style(&self.float.class);
if style.shadow.color.a > 0.0 {
renderer.fill_quad(
renderer::Quad {
bounds: bounds.shrink(1.0),
shadow: style.shadow,
border: border::rounded(
style.shadow_border_radius,
),
snap: false,
},
style.shadow.color,
);
}
}
self.float.content.as_widget().draw(
self.state,
renderer,
theme,
style,
self.layout,
cursor * inverse,
&(self.viewport * inverse),
);
});
});
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
if !cursor.is_over(layout.bounds()) {
return mouse::Interaction::None;
}
let inverse = self.transformation.inverse();
self.float.content.as_widget().mouse_interaction(
self.state,
self.layout,
cursor * inverse,
&(self.viewport * inverse),
renderer,
)
}
fn index(&self) -> f32 {
self.float.scale * 0.5
}
fn overlay<'a>(
&'a mut self,
_layout: Layout<'_>,
renderer: &Renderer,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
self.float.content.as_widget_mut().overlay(
self.state,
self.layout,
renderer,
&(self.viewport * self.transformation.inverse()),
self.transformation.translation(),
)
}
}
/// The theme catalog of a [`Float`].
///
/// All themes that can be used with [`Float`]
/// must implement this trait.
pub trait Catalog {
/// The item class of the [`Catalog`].
type Class<'a>;
/// The default class produced by the [`Catalog`].
fn default<'a>() -> Self::Class<'a>;
/// The [`Style`] of a class with the given status.
fn style(&self, class: &Self::Class<'_>) -> Style;
}
/// A styling function for a [`Float`].
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
impl Catalog for crate::Theme {
type Class<'a> = StyleFn<'a, Self>;
fn default<'a>() -> Self::Class<'a> {
Box::new(|_| Style::default())
}
fn style(&self, class: &Self::Class<'_>) -> Style {
class(self)
}
}
/// The style of a [`Float`].
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Style {
/// The [`Shadow`] of the [`Float`].
pub shadow: Shadow,
/// The border radius of the shadow.
pub shadow_border_radius: border::Radius,
}
-415
View File
@@ -1,415 +0,0 @@
//! Distribute content on a grid.
use crate::core::layout::{self, Layout};
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget::{Operation, Tree};
use crate::core::{
Clipboard, Element, Event, Length, Pixels, Rectangle, Shell, Size, Vector,
Widget,
};
/// A container that distributes its contents on a responsive grid.
pub struct Grid<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> {
spacing: f32,
columns: Constraint,
width: Option<Pixels>,
height: Sizing,
children: Vec<Element<'a, Message, Theme, Renderer>>,
}
enum Constraint {
MaxWidth(Pixels),
Amount(usize),
}
impl<'a, Message, Theme, Renderer> Grid<'a, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
{
/// Creates an empty [`Grid`].
pub fn new() -> Self {
Self::from_vec(Vec::new())
}
/// Creates a [`Grid`] with the given capacity.
pub fn with_capacity(capacity: usize) -> Self {
Self::from_vec(Vec::with_capacity(capacity))
}
/// Creates a [`Grid`] with the given elements.
pub fn with_children(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Self {
let iterator = children.into_iter();
Self::with_capacity(iterator.size_hint().0).extend(iterator)
}
/// Creates a [`Grid`] from an already allocated [`Vec`].
pub fn from_vec(
children: Vec<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
spacing: 0.0,
columns: Constraint::Amount(3),
width: None,
height: Sizing::AspectRatio(1.0),
children,
}
}
/// Sets the spacing _between_ cells in the [`Grid`].
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
self.spacing = amount.into().0;
self
}
/// Sets the width of the [`Grid`] in [`Pixels`].
///
/// By default, a [`Grid`] will [`Fill`] its parent.
///
/// [`Fill`]: Length::Fill
pub fn width(mut self, width: impl Into<Pixels>) -> Self {
self.width = Some(width.into());
self
}
/// Sets the height of the [`Grid`].
///
/// By default, a [`Grid`] uses a cell aspect ratio of `1.0` (i.e. squares).
pub fn height(mut self, height: impl Into<Sizing>) -> Self {
self.height = height.into();
self
}
/// Sets the amount of columns in the [`Grid`].
pub fn columns(mut self, column: usize) -> Self {
self.columns = Constraint::Amount(column);
self
}
/// Makes the amount of columns dynamic in the [`Grid`], never
/// exceeding the provided `max_width`.
pub fn fluid(mut self, max_width: impl Into<Pixels>) -> Self {
self.columns = Constraint::MaxWidth(max_width.into());
self
}
/// Adds an [`Element`] to the [`Grid`].
pub fn push(
mut self,
child: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
self.children.push(child.into());
self
}
/// Adds an element to the [`Grid`], if `Some`.
pub fn push_maybe(
self,
child: Option<impl Into<Element<'a, Message, Theme, Renderer>>>,
) -> Self {
if let Some(child) = child {
self.push(child)
} else {
self
}
}
/// Extends the [`Grid`] with the given children.
pub fn extend(
self,
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Self {
children.into_iter().fold(self, Self::push)
}
}
impl<Message, Renderer> Default for Grid<'_, Message, Renderer>
where
Renderer: crate::core::Renderer,
{
fn default() -> Self {
Self::new()
}
}
impl<'a, Message, Theme, Renderer: crate::core::Renderer>
FromIterator<Element<'a, Message, Theme, Renderer>>
for Grid<'a, Message, Theme, Renderer>
{
fn from_iter<
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
>(
iter: T,
) -> Self {
Self::with_children(iter)
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Grid<'_, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
{
fn children(&self) -> Vec<Tree> {
self.children.iter().map(Tree::new).collect()
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(&self.children);
}
fn size(&self) -> Size<Length> {
Size {
width: self
.width
.map(|pixels| Length::Fixed(pixels.0))
.unwrap_or(Length::Fill),
height: match self.height {
Sizing::AspectRatio(_) => Length::Shrink,
Sizing::EvenlyDistribute(length) => length,
},
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let size = self.size();
let limits = limits.width(size.width).height(size.height);
let available = limits.max();
let cells_per_row = match self.columns {
// width = n * (cell + spacing) - spacing, given n > 0
Constraint::MaxWidth(pixels) => ((available.width + self.spacing)
/ (pixels.0 + self.spacing))
.ceil() as usize,
Constraint::Amount(amount) => amount,
};
if self.children.is_empty() || cells_per_row == 0 {
return layout::Node::new(limits.resolve(
size.width,
size.height,
Size::ZERO,
));
}
let cell_width = (available.width
- self.spacing * (cells_per_row - 1) as f32)
/ cells_per_row as f32;
let cell_height = match self.height {
Sizing::AspectRatio(ratio) => Some(cell_width / ratio),
Sizing::EvenlyDistribute(Length::Shrink) => None,
Sizing::EvenlyDistribute(_) => {
let total_rows = self.children.len().div_ceil(cells_per_row);
Some(
(available.height - self.spacing * (total_rows - 1) as f32)
/ total_rows as f32,
)
}
};
let cell_limits = layout::Limits::new(
Size::new(cell_width, cell_height.unwrap_or(0.0)),
Size::new(cell_width, cell_height.unwrap_or(available.height)),
);
let mut nodes = Vec::with_capacity(self.children.len());
let mut x = 0.0;
let mut y = 0.0;
let mut row_height = 0.0f32;
for (i, (child, tree)) in
self.children.iter_mut().zip(&mut tree.children).enumerate()
{
let node = child
.as_widget_mut()
.layout(tree, renderer, &cell_limits)
.move_to((x, y));
let size = node.size();
x += size.width + self.spacing;
row_height = row_height.max(size.height);
if (i + 1) % cells_per_row == 0 {
y += cell_height.unwrap_or(row_height) + self.spacing;
x = 0.0;
row_height = 0.0;
}
nodes.push(node);
}
if x == 0.0 {
y -= self.spacing;
} else {
y += cell_height.unwrap_or(row_height);
}
layout::Node::with_children(Size::new(available.width, y), nodes)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
.for_each(|((child, state), layout)| {
child
.as_widget_mut()
.operate(state, layout, renderer, operation);
});
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
for ((child, tree), layout) in self
.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
{
child.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell,
viewport,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.map(|((child, tree), layout)| {
child
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
})
.max()
.unwrap_or_default()
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
if let Some(viewport) = layout.bounds().intersection(viewport) {
for ((child, tree), layout) in self
.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.filter(|(_, layout)| layout.bounds().intersects(&viewport))
{
child.as_widget().draw(
tree, renderer, theme, style, layout, cursor, &viewport,
);
}
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
overlay::from_children(
&mut self.children,
tree,
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message, Theme, Renderer> From<Grid<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: crate::core::Renderer + 'a,
{
fn from(row: Grid<'a, Message, Theme, Renderer>) -> Self {
Self::new(row)
}
}
/// The sizing strategy of a [`Grid`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Sizing {
/// The [`Grid`] will ensure each cell follows the given aspect ratio and the
/// total size will be the sum of the cells and the spacing between them.
///
/// The ratio is the amount of horizontal pixels per each vertical pixel of a cell
/// in the [`Grid`].
AspectRatio(f32),
/// The [`Grid`] will evenly distribute the space available in the given [`Length`]
/// for each cell.
EvenlyDistribute(Length),
}
impl From<f32> for Sizing {
fn from(height: f32) -> Self {
Self::EvenlyDistribute(Length::from(height))
}
}
impl From<Length> for Sizing {
fn from(height: Length) -> Self {
Self::EvenlyDistribute(height)
}
}
/// Creates a new [`Sizing`] strategy that maintains the given aspect ratio.
pub fn aspect_ratio(
width: impl Into<Pixels>,
height: impl Into<Pixels>,
) -> Sizing {
Sizing::AspectRatio(width.into().0 / height.into().0)
}
File diff suppressed because it is too large Load Diff
-418
View File
@@ -1,418 +0,0 @@
//! Images display raster graphics in different formats (PNG, JPG, etc.).
//!
//! # 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::image;
//!
//! enum Message {
//! // ...
//! }
//!
//! fn view(state: &State) -> Element<'_, Message> {
//! image("ferris.png").into()
//! }
//! ```
//! <img src="https://github.com/iced-rs/iced/blob/9712b319bb7a32848001b96bd84977430f14b623/examples/resources/ferris.png?raw=true" width="300">
pub mod viewer;
pub use viewer::Viewer;
use crate::core::border;
use crate::core::image;
use crate::core::layout;
use crate::core::mouse;
use crate::core::renderer;
use crate::core::widget::Tree;
use crate::core::{
ContentFit, Element, Layout, Length, Point, Rectangle, Rotation, Size,
Vector, Widget,
};
pub use image::{FilterMethod, Handle};
/// Creates a new [`Viewer`] with the given image `Handle`.
pub fn viewer<Handle>(handle: Handle) -> Viewer<Handle> {
Viewer::new(handle)
}
/// A frame that displays an image while keeping aspect ratio.
///
/// # 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::image;
///
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// image("ferris.png").into()
/// }
/// ```
/// <img src="https://github.com/iced-rs/iced/blob/9712b319bb7a32848001b96bd84977430f14b623/examples/resources/ferris.png?raw=true" width="300">
pub struct Image<Handle = image::Handle> {
handle: Handle,
width: Length,
height: Length,
crop: Option<Rectangle<u32>>,
border_radius: border::Radius,
content_fit: ContentFit,
filter_method: FilterMethod,
rotation: Rotation,
opacity: f32,
scale: f32,
expand: bool,
}
impl<Handle> Image<Handle> {
/// Creates a new [`Image`] with the given path.
pub fn new(handle: impl Into<Handle>) -> Self {
Image {
handle: handle.into(),
width: Length::Shrink,
height: Length::Shrink,
crop: None,
border_radius: border::Radius::default(),
content_fit: ContentFit::default(),
filter_method: FilterMethod::default(),
rotation: Rotation::default(),
opacity: 1.0,
scale: 1.0,
expand: false,
}
}
/// Sets the width of the [`Image`] boundaries.
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Image`] boundaries.
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets whether the [`Image`] should try to fill as much space
/// available as possible while keeping aspect ratio and without
/// allocating extra space in any axis with a [`Length::Shrink`]
/// sizing strategy.
///
/// This is similar to using [`Length::Fill`] for both the
/// [`width`](Self::width) and the [`height`](Self::height),
/// but without the downside of blank space.
pub fn expand(mut self, expand: bool) -> Self {
self.expand = expand;
self
}
/// Sets the [`ContentFit`] of the [`Image`].
///
/// Defaults to [`ContentFit::Contain`]
pub fn content_fit(mut self, content_fit: ContentFit) -> Self {
self.content_fit = content_fit;
self
}
/// Sets the [`FilterMethod`] of the [`Image`].
pub fn filter_method(mut self, filter_method: FilterMethod) -> Self {
self.filter_method = filter_method;
self
}
/// Applies the given [`Rotation`] to the [`Image`].
pub fn rotation(mut self, rotation: impl Into<Rotation>) -> Self {
self.rotation = rotation.into();
self
}
/// Sets the opacity of the [`Image`].
///
/// It should be in the [0.0, 1.0] range—`0.0` meaning completely transparent,
/// and `1.0` meaning completely opaque.
pub fn opacity(mut self, opacity: impl Into<f32>) -> Self {
self.opacity = opacity.into();
self
}
/// Sets the scale of the [`Image`].
///
/// The region of the [`Image`] drawn will be scaled from the center by the given scale factor.
/// This can be useful to create certain effects and animations, like smooth zoom in / out.
pub fn scale(mut self, scale: impl Into<f32>) -> Self {
self.scale = scale.into();
self
}
/// Crops the [`Image`] to the given region described by the [`Rectangle`] in absolute
/// coordinates.
///
/// Cropping is done before applying any transformation or [`ContentFit`]. In practice,
/// this means that cropping an [`Image`] with this method should produce the same result
/// as cropping it externally (e.g. with an image editor) and creating a new [`Handle`]
/// for the cropped version.
///
/// However, this method is much more efficient; since it just leverages scissoring during
/// rendering and no image cropping actually takes place. Instead, it reuses the existing
/// image allocations and should be as efficient as not cropping at all!
///
/// The `region` coordinates will be clamped to the image dimensions, if necessary.
pub fn crop(mut self, region: Rectangle<u32>) -> Self {
self.crop = Some(region);
self
}
/// Sets the [`border::Radius`] of the [`Image`].
///
/// Currently, it will only be applied around the rectangular bounding box
/// of the [`Image`].
pub fn border_radius(
mut self,
border_radius: impl Into<border::Radius>,
) -> Self {
self.border_radius = border_radius.into();
self
}
}
/// Computes the layout of an [`Image`].
pub fn layout<Renderer, Handle>(
renderer: &Renderer,
limits: &layout::Limits,
handle: &Handle,
width: Length,
height: Length,
region: Option<Rectangle<u32>>,
content_fit: ContentFit,
rotation: Rotation,
expand: bool,
) -> layout::Node
where
Renderer: image::Renderer<Handle = Handle>,
{
// The raw w/h of the underlying image
let image_size =
crop(renderer.measure_image(handle).unwrap_or_default(), region);
// The rotated size of the image
let rotated_size = rotation.apply(image_size);
// The size to be available to the widget prior to `Shrink`ing
let bounds = if expand {
limits.width(width).height(height).max()
} else {
limits.resolve(width, height, rotated_size)
};
// The uncropped size of the image when fit to the bounds above
let full_size = content_fit.fit(rotated_size, bounds);
// Shrink the widget to fit the resized image, if requested
let final_size = Size {
width: match width {
Length::Shrink => f32::min(bounds.width, full_size.width),
_ => bounds.width,
},
height: match height {
Length::Shrink => f32::min(bounds.height, full_size.height),
_ => bounds.height,
},
};
layout::Node::new(final_size)
}
fn drawing_bounds<Renderer, Handle>(
renderer: &Renderer,
bounds: Rectangle,
handle: &Handle,
region: Option<Rectangle<u32>>,
content_fit: ContentFit,
rotation: Rotation,
scale: f32,
) -> Rectangle
where
Renderer: image::Renderer<Handle = Handle>,
{
let original_size = renderer.measure_image(handle).unwrap_or_default();
let image_size = crop(original_size, region);
let rotated_size = rotation.apply(image_size);
let adjusted_fit = content_fit.fit(rotated_size, bounds.size());
let fit_scale = Vector::new(
adjusted_fit.width / rotated_size.width,
adjusted_fit.height / rotated_size.height,
);
let final_size = image_size * fit_scale * scale;
let (crop_offset, final_size) = if let Some(region) = region {
let x = region.x.min(original_size.width) as f32;
let y = region.y.min(original_size.height) as f32;
let width = image_size.width;
let height = image_size.height;
let ratio = Vector::new(
original_size.width as f32 / width,
original_size.height as f32 / height,
);
let final_size = final_size * ratio;
let scale = Vector::new(
final_size.width / original_size.width as f32,
final_size.height / original_size.height as f32,
);
let offset = match content_fit {
ContentFit::None => Vector::new(x * scale.x, y * scale.y),
_ => Vector::new(
((original_size.width as f32 - width) / 2.0 - x) * scale.x,
((original_size.height as f32 - height) / 2.0 - y) * scale.y,
),
};
(offset, final_size)
} else {
(Vector::ZERO, final_size)
};
let position = match content_fit {
ContentFit::None => Point::new(
bounds.x + (rotated_size.width - adjusted_fit.width) / 2.0,
bounds.y + (rotated_size.height - adjusted_fit.height) / 2.0,
),
_ => Point::new(
bounds.center_x() - final_size.width / 2.0,
bounds.center_y() - final_size.height / 2.0,
),
};
Rectangle::new(position + crop_offset, final_size)
}
fn crop(size: Size<u32>, region: Option<Rectangle<u32>>) -> Size<f32> {
if let Some(region) = region {
Size::new(
region.width.min(size.width) as f32,
region.height.min(size.height) as f32,
)
} else {
Size::new(size.width as f32, size.height as f32)
}
}
/// Draws an [`Image`]
pub fn draw<Renderer, Handle>(
renderer: &mut Renderer,
layout: Layout<'_>,
handle: &Handle,
crop: Option<Rectangle<u32>>,
border_radius: border::Radius,
content_fit: ContentFit,
filter_method: FilterMethod,
rotation: Rotation,
opacity: f32,
scale: f32,
) where
Renderer: image::Renderer<Handle = Handle>,
Handle: Clone,
{
let bounds = layout.bounds();
let drawing_bounds = drawing_bounds(
renderer,
bounds,
handle,
crop,
content_fit,
rotation,
scale,
);
renderer.draw_image(
image::Image {
handle: handle.clone(),
border_radius,
filter_method,
rotation: rotation.radians(),
opacity,
snap: true,
},
drawing_bounds,
bounds,
);
}
impl<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer>
for Image<Handle>
where
Renderer: image::Renderer<Handle = Handle>,
Handle: Clone,
{
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(
renderer,
limits,
&self.handle,
self.width,
self.height,
self.crop,
self.content_fit,
self.rotation,
self.expand,
)
}
fn draw(
&self,
_tree: &Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
draw(
renderer,
layout,
&self.handle,
self.crop,
self.border_radius,
self.content_fit,
self.filter_method,
self.rotation,
self.opacity,
self.scale,
);
}
}
impl<'a, Message, Theme, Renderer, Handle> From<Image<Handle>>
for Element<'a, Message, Theme, Renderer>
where
Renderer: image::Renderer<Handle = Handle>,
Handle: Clone + 'a,
{
fn from(image: Image<Handle>) -> Element<'a, Message, Theme, Renderer> {
Element::new(image)
}
}
-447
View File
@@ -1,447 +0,0 @@
//! Zoom and pan on an image.
use crate::core::border;
use crate::core::image::{self, FilterMethod};
use crate::core::layout;
use crate::core::mouse;
use crate::core::renderer;
use crate::core::widget::tree::{self, Tree};
use crate::core::{
Clipboard, ContentFit, Element, Event, Image, Layout, Length, Pixels,
Point, Radians, Rectangle, Shell, Size, Vector, Widget,
};
/// A frame that displays an image with the ability to zoom in/out and pan.
pub struct Viewer<Handle> {
padding: f32,
width: Length,
height: Length,
min_scale: f32,
max_scale: f32,
scale_step: f32,
handle: Handle,
filter_method: FilterMethod,
content_fit: ContentFit,
}
impl<Handle> Viewer<Handle> {
/// Creates a new [`Viewer`] with the given [`State`].
pub fn new<T: Into<Handle>>(handle: T) -> Self {
Viewer {
handle: handle.into(),
padding: 0.0,
width: Length::Shrink,
height: Length::Shrink,
min_scale: 0.25,
max_scale: 10.0,
scale_step: 0.10,
filter_method: FilterMethod::default(),
content_fit: ContentFit::default(),
}
}
/// Sets the [`FilterMethod`] of the [`Viewer`].
pub fn filter_method(mut self, filter_method: image::FilterMethod) -> Self {
self.filter_method = filter_method;
self
}
/// Sets the [`ContentFit`] of the [`Viewer`].
pub fn content_fit(mut self, content_fit: ContentFit) -> Self {
self.content_fit = content_fit;
self
}
/// Sets the padding of the [`Viewer`].
pub fn padding(mut self, padding: impl Into<Pixels>) -> Self {
self.padding = padding.into().0;
self
}
/// Sets the width of the [`Viewer`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Viewer`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets the max scale applied to the image of the [`Viewer`].
///
/// Default is `10.0`
pub fn max_scale(mut self, max_scale: f32) -> Self {
self.max_scale = max_scale;
self
}
/// Sets the min scale applied to the image of the [`Viewer`].
///
/// Default is `0.25`
pub fn min_scale(mut self, min_scale: f32) -> Self {
self.min_scale = min_scale;
self
}
/// Sets the percentage the image of the [`Viewer`] will be scaled by
/// when zoomed in / out.
///
/// Default is `0.10`
pub fn scale_step(mut self, scale_step: f32) -> Self {
self.scale_step = scale_step;
self
}
}
impl<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer>
for Viewer<Handle>
where
Renderer: image::Renderer<Handle = Handle>,
Handle: Clone,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::new())
}
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 {
// The raw w/h of the underlying image
let image_size =
renderer.measure_image(&self.handle).unwrap_or_default();
let image_size =
Size::new(image_size.width as f32, image_size.height as f32);
// The size to be available to the widget prior to `Shrink`ing
let raw_size = limits.resolve(self.width, self.height, image_size);
// The uncropped size of the image when fit to the bounds above
let full_size = self.content_fit.fit(image_size, raw_size);
// Shrink the widget to fit the resized image, if requested
let final_size = Size {
width: match self.width {
Length::Shrink => f32::min(raw_size.width, full_size.width),
_ => raw_size.width,
},
height: match self.height {
Length::Shrink => f32::min(raw_size.height, full_size.height),
_ => raw_size.height,
},
};
layout::Node::new(final_size)
}
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();
match event {
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let Some(cursor_position) = cursor.position_over(bounds) else {
return;
};
match *delta {
mouse::ScrollDelta::Lines { y, .. }
| mouse::ScrollDelta::Pixels { y, .. } => {
let state = tree.state.downcast_mut::<State>();
let previous_scale = state.scale;
if y < 0.0 && previous_scale > self.min_scale
|| y > 0.0 && previous_scale < self.max_scale
{
state.scale = (if y > 0.0 {
state.scale * (1.0 + self.scale_step)
} else {
state.scale / (1.0 + self.scale_step)
})
.clamp(self.min_scale, self.max_scale);
let scaled_size = scaled_image_size(
renderer,
&self.handle,
state,
bounds.size(),
self.content_fit,
);
let factor = state.scale / previous_scale - 1.0;
let cursor_to_center =
cursor_position - bounds.center();
let adjustment = cursor_to_center * factor
+ state.current_offset * factor;
state.current_offset = Vector::new(
if scaled_size.width > bounds.width {
state.current_offset.x + adjustment.x
} else {
0.0
},
if scaled_size.height > bounds.height {
state.current_offset.y + adjustment.y
} else {
0.0
},
);
}
}
}
shell.request_redraw();
shell.capture_event();
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(cursor_position) = cursor.position_over(bounds) else {
return;
};
let state = tree.state.downcast_mut::<State>();
state.cursor_grabbed_at = Some(cursor_position);
state.starting_offset = state.current_offset;
shell.capture_event();
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let state = tree.state.downcast_mut::<State>();
state.cursor_grabbed_at = None;
}
Event::Mouse(mouse::Event::CursorMoved { position }) => {
let state = tree.state.downcast_mut::<State>();
if let Some(origin) = state.cursor_grabbed_at {
let scaled_size = scaled_image_size(
renderer,
&self.handle,
state,
bounds.size(),
self.content_fit,
);
let hidden_width = (scaled_size.width - bounds.width / 2.0)
.max(0.0)
.round();
let hidden_height = (scaled_size.height
- bounds.height / 2.0)
.max(0.0)
.round();
let delta = *position - origin;
let x = if bounds.width < scaled_size.width {
(state.starting_offset.x - delta.x)
.clamp(-hidden_width, hidden_width)
} else {
0.0
};
let y = if bounds.height < scaled_size.height {
(state.starting_offset.y - delta.y)
.clamp(-hidden_height, hidden_height)
} else {
0.0
};
state.current_offset = Vector::new(x, y);
shell.request_redraw();
shell.capture_event();
}
}
_ => {}
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds();
let is_mouse_over = cursor.is_over(bounds);
if state.is_cursor_grabbed() {
mouse::Interaction::Grabbing
} else if is_mouse_over {
mouse::Interaction::Grab
} else {
mouse::Interaction::None
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds();
let final_size = scaled_image_size(
renderer,
&self.handle,
state,
bounds.size(),
self.content_fit,
);
let translation = {
let diff_w = bounds.width - final_size.width;
let diff_h = bounds.height - final_size.height;
let image_top_left = match self.content_fit {
ContentFit::None => {
Vector::new(diff_w.max(0.0) / 2.0, diff_h.max(0.0) / 2.0)
}
_ => Vector::new(diff_w / 2.0, diff_h / 2.0),
};
image_top_left - state.offset(bounds, final_size)
};
let drawing_bounds = Rectangle::new(bounds.position(), final_size);
let render = |renderer: &mut Renderer| {
renderer.with_translation(translation, |renderer| {
renderer.draw_image(
Image {
handle: self.handle.clone(),
border_radius: border::Radius::default(),
filter_method: self.filter_method,
rotation: Radians(0.0),
opacity: 1.0,
snap: true,
},
drawing_bounds,
*viewport - translation,
);
});
};
renderer.with_layer(bounds, render);
}
}
/// The local state of a [`Viewer`].
#[derive(Debug, Clone, Copy)]
pub struct State {
scale: f32,
starting_offset: Vector,
current_offset: Vector,
cursor_grabbed_at: Option<Point>,
}
impl Default for State {
fn default() -> Self {
Self {
scale: 1.0,
starting_offset: Vector::default(),
current_offset: Vector::default(),
cursor_grabbed_at: None,
}
}
}
impl State {
/// Creates a new [`State`].
pub fn new() -> Self {
State::default()
}
/// Returns the current offset of the [`State`], given the bounds
/// of the [`Viewer`] and its image.
fn offset(&self, bounds: Rectangle, image_size: Size) -> Vector {
let hidden_width =
(image_size.width - bounds.width / 2.0).max(0.0).round();
let hidden_height =
(image_size.height - bounds.height / 2.0).max(0.0).round();
Vector::new(
self.current_offset.x.clamp(-hidden_width, hidden_width),
self.current_offset.y.clamp(-hidden_height, hidden_height),
)
}
/// Returns if the cursor is currently grabbed by the [`Viewer`].
pub fn is_cursor_grabbed(&self) -> bool {
self.cursor_grabbed_at.is_some()
}
}
impl<'a, Message, Theme, Renderer, Handle> From<Viewer<Handle>>
for Element<'a, Message, Theme, Renderer>
where
Renderer: 'a + image::Renderer<Handle = Handle>,
Message: 'a,
Handle: Clone + 'a,
{
fn from(viewer: Viewer<Handle>) -> Element<'a, Message, Theme, Renderer> {
Element::new(viewer)
}
}
/// Returns the bounds of the underlying image, given the bounds of
/// the [`Viewer`]. Scaling will be applied and original aspect ratio
/// will be respected.
pub fn scaled_image_size<Renderer>(
renderer: &Renderer,
handle: &<Renderer as image::Renderer>::Handle,
state: &State,
bounds: Size,
content_fit: ContentFit,
) -> Size
where
Renderer: image::Renderer,
{
let Size { width, height } =
renderer.measure_image(handle).unwrap_or_default();
let image_size = Size::new(width as f32, height as f32);
let adjusted_fit = content_fit.fit(image_size, bounds);
Size::new(
adjusted_fit.width * state.scale,
adjusted_fit.height * state.scale,
)
}
-75
View File
@@ -1,75 +0,0 @@
//! Keyed widgets can provide hints to ensure continuity.
//!
//! # What is continuity?
//! Continuity is the feeling of persistence of state.
//!
//! In a graphical user interface, users expect widgets to have a
//! certain degree of continuous state. For instance, a text input
//! that is focused should stay focused even if the widget tree
//! changes slightly.
//!
//! Continuity is tricky in `iced` and the Elm Architecture because
//! the whole widget tree is rebuilt during every `view` call. This is
//! very convenient from a developer perspective because you can build
//! extremely dynamic interfaces without worrying about changing state.
//!
//! However, the tradeoff is that determining what changed becomes hard
//! for `iced`. If you have a list of things, adding an element at the
//! top may cause a loss of continuity on every element on the list!
//!
//! # How can we keep continuity?
//! The good news is that user interfaces generally have a static widget
//! structure. This structure can be relied on to ensure some degree of
//! continuity. `iced` already does this.
//!
//! However, sometimes you have a certain part of your interface that is
//! quite dynamic. For instance, a list of things where items may be added
//! or removed at any place.
//!
//! There are different ways to mitigate this during the reconciliation
//! stage, but they involve comparing trees at certain depths and
//! backtracking... Quite computationally expensive.
//!
//! One approach that is cheaper consists in letting the user provide some hints
//! about the identities of the different widgets so that they can be compared
//! directly without going deeper.
//!
//! The widgets in this module will all ask for a "hint" of some sort. In order
//! to help them keep continuity, you need to make sure the hint stays the same
//! for the same items in your user interface between `view` calls.
pub mod column;
pub use column::Column;
/// Creates a keyed [`Column`] with the given children.
///
/// Keyed columns distribute content vertically while keeping continuity.
///
/// # 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::keyed_column;
///
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// keyed_column![
/// (0, "Item 0"),
/// (1, "Item 1"),
/// (2, "Item 2"),
/// ].into()
/// }
/// ```
#[macro_export]
macro_rules! keyed_column {
() => (
$crate::keyed::Column::new()
);
($(($key:expr, $x:expr)),+ $(,)?) => (
$crate::keyed::Column::with_children(vec![$(($key, $crate::core::Element::from($x))),+])
);
}
-398
View File
@@ -1,398 +0,0 @@
//! Keyed columns distribute content vertically while keeping continuity.
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget::Operation;
use crate::core::widget::tree::{self, Tree};
use crate::core::{
Alignment, Clipboard, Element, Event, Layout, Length, Padding, Pixels,
Rectangle, Shell, Size, Vector, Widget,
};
/// A container that distributes its contents vertically while keeping continuity.
///
/// # 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::{keyed_column, text};
///
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// keyed_column((0..=100).map(|i| {
/// (i, text!("Item {i}").into())
/// })).into()
/// }
/// ```
pub struct Column<
'a,
Key,
Message,
Theme = crate::Theme,
Renderer = crate::Renderer,
> where
Key: Copy + PartialEq,
{
spacing: f32,
padding: Padding,
width: Length,
height: Length,
max_width: f32,
align_items: Alignment,
keys: Vec<Key>,
children: Vec<Element<'a, Message, Theme, Renderer>>,
}
impl<'a, Key, Message, Theme, Renderer>
Column<'a, Key, Message, Theme, Renderer>
where
Key: Copy + PartialEq,
Renderer: crate::core::Renderer,
{
/// Creates an empty [`Column`].
pub fn new() -> Self {
Self::from_vecs(Vec::new(), Vec::new())
}
/// Creates a [`Column`] from already allocated [`Vec`]s.
///
/// Keep in mind that the [`Column`] will not inspect the [`Vec`]s, 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_vecs(
keys: Vec<Key>,
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_items: Alignment::Start,
keys,
children,
}
}
/// Creates a [`Column`] with the given capacity.
pub fn with_capacity(capacity: usize) -> Self {
Self::from_vecs(
Vec::with_capacity(capacity),
Vec::with_capacity(capacity),
)
}
/// Creates a [`Column`] with the given elements.
pub fn with_children(
children: impl IntoIterator<
Item = (Key, Element<'a, Message, Theme, Renderer>),
>,
) -> Self {
let iterator = children.into_iter();
Self::with_capacity(iterator.size_hint().0).extend(iterator)
}
/// 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_items(mut self, align: Alignment) -> Self {
self.align_items = align;
self
}
/// Adds an element to the [`Column`].
pub fn push(
mut self,
key: Key,
child: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let child = child.into();
let child_size = child.as_widget().size_hint();
self.width = self.width.enclose(child_size.width);
self.height = self.height.enclose(child_size.height);
self.keys.push(key);
self.children.push(child);
self
}
/// Adds an element to the [`Column`], if `Some`.
pub fn push_maybe(
self,
key: Key,
child: Option<impl Into<Element<'a, Message, Theme, Renderer>>>,
) -> Self {
if let Some(child) = child {
self.push(key, child)
} else {
self
}
}
/// Extends the [`Column`] with the given children.
pub fn extend(
self,
children: impl IntoIterator<
Item = (Key, Element<'a, Message, Theme, Renderer>),
>,
) -> Self {
children
.into_iter()
.fold(self, |column, (key, child)| column.push(key, child))
}
}
impl<Key, Message, Renderer> Default for Column<'_, Key, Message, Renderer>
where
Key: Copy + PartialEq,
Renderer: crate::core::Renderer,
{
fn default() -> Self {
Self::new()
}
}
struct State<Key>
where
Key: Copy + PartialEq,
{
keys: Vec<Key>,
}
impl<Key, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Column<'_, Key, Message, Theme, Renderer>
where
Renderer: crate::core::Renderer,
Key: Copy + PartialEq + 'static,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State<Key>>()
}
fn state(&self) -> tree::State {
tree::State::new(State {
keys: self.keys.clone(),
})
}
fn children(&self) -> Vec<Tree> {
self.children.iter().map(Tree::new).collect()
}
fn diff(&self, tree: &mut Tree) {
let Tree {
state, children, ..
} = tree;
let state = state.downcast_mut::<State<Key>>();
tree::diff_children_custom_with_search(
children,
&self.children,
|tree, child| child.as_widget().diff(tree),
|index| {
self.keys.get(index).or_else(|| self.keys.last()).copied()
!= Some(state.keys[index])
},
|child| Tree::new(child.as_widget()),
);
if state.keys != self.keys {
state.keys.clone_from(&self.keys);
}
}
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)
.width(self.width)
.height(self.height);
layout::flex::resolve(
layout::flex::Axis::Vertical,
renderer,
&limits,
self.width,
self.height,
self.padding,
self.spacing,
self.align_items,
&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,
) {
for ((child, state), layout) in self
.children
.iter()
.zip(&tree.children)
.zip(layout.children())
{
child
.as_widget()
.draw(state, 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, Key, Message, Theme, Renderer>
From<Column<'a, Key, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Key: Copy + PartialEq + 'static,
Message: 'a,
Theme: 'a,
Renderer: crate::core::Renderer + 'a,
{
fn from(column: Column<'a, Key, Message, Theme, Renderer>) -> Self {
Self::new(column)
}
}
-417
View File
@@ -1,417 +0,0 @@
#![allow(clippy::await_holding_refcell_ref, clippy::type_complexity)]
pub(crate) mod helpers;
pub mod component;
#[allow(deprecated)]
pub use component::Component;
mod cache;
use crate::core::Element;
use crate::core::layout::{self, Layout};
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget::tree::{self, Tree};
use crate::core::widget::{self, Widget};
use crate::core::{
self, Clipboard, Event, Length, Rectangle, Shell, Size, Vector,
};
use ouroboros::self_referencing;
use rustc_hash::FxHasher;
use std::cell::RefCell;
use std::hash::{Hash, Hasher as H};
use std::rc::Rc;
/// A widget that only rebuilds its contents when necessary.
#[cfg(feature = "lazy")]
pub struct Lazy<'a, Message, Theme, Renderer, Dependency, View> {
dependency: Dependency,
view: Box<dyn Fn(&Dependency) -> View + 'a>,
element: RefCell<
Option<Rc<RefCell<Option<Element<'static, Message, Theme, Renderer>>>>>,
>,
}
impl<'a, Message, Theme, Renderer, Dependency, View>
Lazy<'a, Message, Theme, Renderer, Dependency, View>
where
Dependency: Hash + 'a,
View: Into<Element<'static, Message, Theme, Renderer>>,
{
/// Creates a new [`Lazy`] widget with the given data `Dependency` and a
/// closure that can turn this data into a widget tree.
pub fn new(
dependency: Dependency,
view: impl Fn(&Dependency) -> View + 'a,
) -> Self {
Self {
dependency,
view: Box::new(view),
element: RefCell::new(None),
}
}
fn with_element<T>(
&self,
f: impl FnOnce(&Element<'_, Message, Theme, Renderer>) -> T,
) -> T {
f(self
.element
.borrow()
.as_ref()
.unwrap()
.borrow()
.as_ref()
.unwrap())
}
fn with_element_mut<T>(
&self,
f: impl FnOnce(&mut Element<'_, Message, Theme, Renderer>) -> T,
) -> T {
f(self
.element
.borrow()
.as_ref()
.unwrap()
.borrow_mut()
.as_mut()
.unwrap())
}
}
struct Internal<Message, Theme, Renderer> {
element: Rc<RefCell<Option<Element<'static, Message, Theme, Renderer>>>>,
hash: u64,
}
impl<'a, Message, Theme, Renderer, Dependency, View>
Widget<Message, Theme, Renderer>
for Lazy<'a, Message, Theme, Renderer, Dependency, View>
where
View: Into<Element<'static, Message, Theme, Renderer>> + 'static,
Dependency: Hash + 'a,
Message: 'static,
Theme: 'static,
Renderer: core::Renderer + 'static,
{
fn tag(&self) -> tree::Tag {
struct Tag<T>(T);
tree::Tag::of::<Tag<View>>()
}
fn state(&self) -> tree::State {
let hash = {
let mut hasher = FxHasher::default();
self.dependency.hash(&mut hasher);
hasher.finish()
};
let element =
Rc::new(RefCell::new(Some((self.view)(&self.dependency).into())));
(*self.element.borrow_mut()) = Some(element.clone());
tree::State::new(Internal { element, hash })
}
fn children(&self) -> Vec<Tree> {
self.with_element(|element| vec![Tree::new(element.as_widget())])
}
fn diff(&self, tree: &mut Tree) {
let current = tree
.state
.downcast_mut::<Internal<Message, Theme, Renderer>>();
let new_hash = {
let mut hasher = FxHasher::default();
self.dependency.hash(&mut hasher);
hasher.finish()
};
if current.hash != new_hash {
current.hash = new_hash;
let element = (self.view)(&self.dependency).into();
current.element = Rc::new(RefCell::new(Some(element)));
(*self.element.borrow_mut()) = Some(current.element.clone());
self.with_element(|element| {
tree.diff_children(std::slice::from_ref(&element.as_widget()));
});
} else {
(*self.element.borrow_mut()) = Some(current.element.clone());
}
}
fn size(&self) -> Size<Length> {
self.with_element(|element| element.as_widget().size())
}
fn size_hint(&self) -> Size<Length> {
Size {
width: Length::Shrink,
height: Length::Shrink,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.with_element_mut(|element| {
element.as_widget_mut().layout(
&mut tree.children[0],
renderer,
limits,
)
})
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.with_element_mut(|element| {
element.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.with_element_mut(|element| {
element.as_widget_mut().update(
&mut tree.children[0],
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.with_element(|element| {
element.as_widget().mouse_interaction(
&tree.children[0],
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.with_element(|element| {
element.as_widget().draw(
&tree.children[0],
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>> {
let overlay = InnerBuilder {
cell: self.element.borrow().as_ref().unwrap().clone(),
element: self
.element
.borrow()
.as_ref()
.unwrap()
.borrow_mut()
.take()
.unwrap(),
tree: &mut tree.children[0],
layout,
overlay_builder: |element, tree, layout| {
element
.as_widget_mut()
.overlay(tree, *layout, renderer, viewport, translation)
.map(|overlay| RefCell::new(overlay::Nested::new(overlay)))
},
}
.build();
#[allow(clippy::redundant_closure_for_method_calls)]
if overlay.with_overlay(|overlay| overlay.is_some()) {
Some(overlay::Element::new(Box::new(Overlay(Some(overlay)))))
} else {
let heads = overlay.into_heads();
// - You may not like it, but this is what peak performance looks like
// - TODO: Get rid of ouroboros, for good
// - What?!
*self.element.borrow().as_ref().unwrap().borrow_mut() =
Some(heads.element);
None
}
}
}
#[self_referencing]
struct Inner<'a, Message: 'a, Theme: 'a, Renderer: 'a> {
cell: Rc<RefCell<Option<Element<'static, Message, Theme, Renderer>>>>,
element: Element<'static, Message, Theme, Renderer>,
tree: &'a mut Tree,
layout: Layout<'a>,
#[borrows(mut element, mut tree, layout)]
#[not_covariant]
overlay: Option<RefCell<overlay::Nested<'this, Message, Theme, Renderer>>>,
}
struct Overlay<'a, Message, Theme, Renderer>(
Option<Inner<'a, Message, Theme, Renderer>>,
);
impl<Message, Theme, Renderer> Drop for Overlay<'_, Message, Theme, Renderer> {
fn drop(&mut self) {
let heads = self.0.take().unwrap().into_heads();
(*heads.cell.borrow_mut()) = Some(heads.element);
}
}
impl<Message, Theme, Renderer> Overlay<'_, Message, Theme, Renderer> {
fn with_overlay_maybe<T>(
&self,
f: impl FnOnce(&mut overlay::Nested<'_, Message, Theme, Renderer>) -> T,
) -> Option<T> {
self.0.as_ref().unwrap().with_overlay(|overlay| {
overlay.as_ref().map(|nested| (f)(&mut nested.borrow_mut()))
})
}
fn with_overlay_mut_maybe<T>(
&mut self,
f: impl FnOnce(&mut overlay::Nested<'_, Message, Theme, Renderer>) -> T,
) -> Option<T> {
self.0.as_mut().unwrap().with_overlay_mut(|overlay| {
overlay.as_mut().map(|nested| (f)(nested.get_mut()))
})
}
}
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
for Overlay<'_, Message, Theme, Renderer>
where
Renderer: core::Renderer,
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.with_overlay_maybe(|overlay| overlay.layout(renderer, bounds))
.unwrap_or_default()
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
let _ = self.with_overlay_maybe(|overlay| {
overlay.draw(renderer, theme, style, layout, cursor);
});
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
self.with_overlay_maybe(|overlay| {
overlay.mouse_interaction(layout, cursor, renderer)
})
.unwrap_or_default()
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let _ = self.with_overlay_mut_maybe(|overlay| {
overlay.update(event, layout, cursor, renderer, clipboard, shell);
});
}
}
impl<'a, Message, Theme, Renderer, Dependency, View>
From<Lazy<'a, Message, Theme, Renderer, Dependency, View>>
for Element<'a, Message, Theme, Renderer>
where
View: Into<Element<'static, Message, Theme, Renderer>> + 'static,
Renderer: core::Renderer + 'static,
Message: 'static,
Theme: 'static,
Dependency: Hash + 'a,
{
fn from(
lazy: Lazy<'a, Message, Theme, Renderer, Dependency, View>,
) -> Self {
Self::new(lazy)
}
}
-14
View File
@@ -1,14 +0,0 @@
#![allow(dead_code)]
use crate::core::Element;
use crate::core::overlay;
use ouroboros::self_referencing;
#[self_referencing(pub_extras)]
pub struct Cache<'a, Message: 'a, Theme: 'a, Renderer: 'a> {
pub element: Element<'a, Message, Theme, Renderer>,
#[borrows(mut element)]
#[covariant]
overlay: Option<overlay::Element<'this, Message, Theme, Renderer>>,
}
-669
View File
@@ -1,669 +0,0 @@
//! Build and reuse custom widgets using The Elm Architecture.
#![allow(deprecated)]
use crate::core::layout::{self, Layout};
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget;
use crate::core::widget::tree::{self, Tree};
use crate::core::{
self, Clipboard, Element, Length, Rectangle, Shell, Size, Vector, Widget,
};
use ouroboros::self_referencing;
use std::cell::RefCell;
use std::marker::PhantomData;
use std::rc::Rc;
/// A reusable, custom widget that uses The Elm Architecture.
///
/// A [`Component`] allows you to implement custom widgets as if they were
/// `iced` applications with encapsulated state.
///
/// In other words, a [`Component`] allows you to turn `iced` applications into
/// custom widgets and embed them without cumbersome wiring.
///
/// A [`Component`] produces widgets that may fire an [`Event`](Component::Event)
/// and update the internal state of the [`Component`].
///
/// Additionally, a [`Component`] is capable of producing a `Message` to notify
/// the parent application of any relevant interactions.
///
/// # State
/// A component can store its state in one of two ways: either as data within the
/// implementor of the trait, or in a type [`State`][Component::State] that is managed
/// by the runtime and provided to the trait methods. These two approaches are not
/// mutually exclusive and have opposite pros and cons.
///
/// For instance, if a piece of state is needed by multiple components that reside
/// in different branches of the tree, then it's more convenient to let a common
/// ancestor store it and pass it down.
///
/// On the other hand, if a piece of state is only needed by the component itself,
/// you can store it as part of its internal [`State`][Component::State].
#[cfg(feature = "lazy")]
#[deprecated(
since = "0.13.0",
note = "components introduce encapsulated state and hamper the use of a single source of truth. \
Instead, leverage the Elm Architecture directly, or implement a custom widget"
)]
pub trait Component<Message, Theme = crate::Theme, Renderer = crate::Renderer> {
/// The internal state of this [`Component`].
type State: Default;
/// The type of event this [`Component`] handles internally.
type Event;
/// Processes an [`Event`](Component::Event) and updates the [`Component`] state accordingly.
///
/// It can produce a `Message` for the parent application.
fn update(
&mut self,
state: &mut Self::State,
event: Self::Event,
) -> Option<Message>;
/// Produces the widgets of the [`Component`], which may trigger an [`Event`](Component::Event)
/// on user interaction.
fn view(
&self,
state: &Self::State,
) -> Element<'_, Self::Event, Theme, Renderer>;
/// Update the [`Component`] state based on the provided [`Operation`](widget::Operation)
///
/// By default, it does nothing.
fn operate(
&self,
_bounds: Rectangle,
_state: &mut Self::State,
_operation: &mut dyn widget::Operation,
) {
}
/// Returns a [`Size`] hint for laying out the [`Component`].
///
/// This hint may be used by some widget containers to adjust their sizing strategy
/// during construction.
fn size_hint(&self) -> Size<Length> {
Size {
width: Length::Shrink,
height: Length::Shrink,
}
}
}
struct Tag<T>(T);
/// Turns an implementor of [`Component`] into an [`Element`] that can be
/// embedded in any application.
pub fn view<'a, C, Message, Theme, Renderer>(
component: C,
) -> Element<'a, Message, Theme, Renderer>
where
C: Component<Message, Theme, Renderer> + 'a,
C::State: 'static,
Message: 'a,
Theme: 'a,
Renderer: core::Renderer + 'a,
{
Element::new(Instance {
state: RefCell::new(Some(
StateBuilder {
component: Box::new(component),
message: PhantomData,
state: PhantomData,
element_builder: |_| None,
}
.build(),
)),
tree: RefCell::new(Rc::new(RefCell::new(None))),
})
}
struct Instance<'a, Message, Theme, Renderer, Event, S> {
state: RefCell<Option<State<'a, Message, Theme, Renderer, Event, S>>>,
tree: RefCell<Rc<RefCell<Option<Tree>>>>,
}
#[self_referencing]
struct State<'a, Message: 'a, Theme: 'a, Renderer: 'a, Event: 'a, S: 'a> {
component: Box<
dyn Component<Message, Theme, Renderer, Event = Event, State = S> + 'a,
>,
message: PhantomData<Message>,
state: PhantomData<S>,
#[borrows(component)]
#[covariant]
element: Option<Element<'this, Event, Theme, Renderer>>,
}
impl<Message, Theme, Renderer, Event, S>
Instance<'_, Message, Theme, Renderer, Event, S>
where
S: Default + 'static,
Renderer: renderer::Renderer,
{
fn diff_self(&self) {
self.with_element(|element| {
self.tree
.borrow_mut()
.borrow_mut()
.as_mut()
.unwrap()
.diff_children(std::slice::from_ref(&element));
});
}
fn rebuild_element_if_necessary(&self) {
let inner = self.state.borrow_mut().take().unwrap();
if inner.borrow_element().is_none() {
let heads = inner.into_heads();
*self.state.borrow_mut() = Some(
StateBuilder {
component: heads.component,
message: PhantomData,
state: PhantomData,
element_builder: |component| {
Some(
component.view(
self.tree
.borrow()
.borrow()
.as_ref()
.unwrap()
.state
.downcast_ref::<S>(),
),
)
},
}
.build(),
);
self.diff_self();
} else {
*self.state.borrow_mut() = Some(inner);
}
}
fn rebuild_element_with_operation(
&self,
layout: Layout<'_>,
operation: &mut dyn widget::Operation,
) {
let heads = self.state.borrow_mut().take().unwrap().into_heads();
heads.component.operate(
layout.bounds(),
self.tree
.borrow_mut()
.borrow_mut()
.as_mut()
.unwrap()
.state
.downcast_mut(),
operation,
);
*self.state.borrow_mut() = Some(
StateBuilder {
component: heads.component,
message: PhantomData,
state: PhantomData,
element_builder: |component| {
Some(
component.view(
self.tree
.borrow()
.borrow()
.as_ref()
.unwrap()
.state
.downcast_ref(),
),
)
},
}
.build(),
);
self.diff_self();
}
fn with_element<T>(
&self,
f: impl FnOnce(&Element<'_, Event, Theme, Renderer>) -> T,
) -> T {
self.with_element_mut(|element| f(element))
}
fn with_element_mut<T>(
&self,
f: impl FnOnce(&mut Element<'_, Event, Theme, Renderer>) -> T,
) -> T {
self.rebuild_element_if_necessary();
self.state
.borrow_mut()
.as_mut()
.unwrap()
.with_element_mut(|element| f(element.as_mut().unwrap()))
}
}
impl<Message, Theme, Renderer, Event, S> Widget<Message, Theme, Renderer>
for Instance<'_, Message, Theme, Renderer, Event, S>
where
S: 'static + Default,
Renderer: core::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Tag<S>>()
}
fn state(&self) -> tree::State {
let state = Rc::new(RefCell::new(Some(Tree {
tag: tree::Tag::of::<Tag<S>>(),
state: tree::State::new(S::default()),
children: vec![Tree::empty()],
})));
*self.tree.borrow_mut() = state.clone();
self.diff_self();
tree::State::new(state)
}
fn children(&self) -> Vec<Tree> {
vec![]
}
fn diff(&self, tree: &mut Tree) {
let tree = tree.state.downcast_ref::<Rc<RefCell<Option<Tree>>>>();
*self.tree.borrow_mut() = tree.clone();
self.rebuild_element_if_necessary();
}
fn size(&self) -> Size<Length> {
self.with_element(|element| element.as_widget().size())
}
fn size_hint(&self) -> Size<Length> {
self.state
.borrow()
.as_ref()
.expect("Borrow instance state")
.borrow_component()
.size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let t = tree.state.downcast_mut::<Rc<RefCell<Option<Tree>>>>();
self.with_element_mut(|element| {
element.as_widget_mut().layout(
&mut t.borrow_mut().as_mut().unwrap().children[0],
renderer,
limits,
)
})
}
fn update(
&mut self,
tree: &mut Tree,
event: &core::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let mut local_messages = Vec::new();
let mut local_shell = Shell::new(&mut local_messages);
let t = tree.state.downcast_mut::<Rc<RefCell<Option<Tree>>>>();
self.with_element_mut(|element| {
element.as_widget_mut().update(
&mut t.borrow_mut().as_mut().unwrap().children[0],
event,
layout,
cursor,
renderer,
clipboard,
&mut local_shell,
viewport,
);
});
if local_shell.is_event_captured() {
shell.capture_event();
}
local_shell.revalidate_layout(|| shell.invalidate_layout());
shell.request_redraw_at(local_shell.redraw_request());
shell.request_input_method(local_shell.input_method());
if !local_messages.is_empty() {
let mut heads = self.state.take().unwrap().into_heads();
for message in local_messages.into_iter().filter_map(|message| {
heads.component.update(
t.borrow_mut().as_mut().unwrap().state.downcast_mut(),
message,
)
}) {
shell.publish(message);
}
self.state = RefCell::new(Some(
StateBuilder {
component: heads.component,
message: PhantomData,
state: PhantomData,
element_builder: |_| None,
}
.build(),
));
shell.invalidate_layout();
shell.request_redraw();
}
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.rebuild_element_with_operation(layout, operation);
let tree = tree.state.downcast_mut::<Rc<RefCell<Option<Tree>>>>();
self.with_element_mut(|element| {
element.as_widget_mut().operate(
&mut tree.borrow_mut().as_mut().unwrap().children[0],
layout,
renderer,
operation,
);
});
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let tree = tree.state.downcast_ref::<Rc<RefCell<Option<Tree>>>>();
self.with_element(|element| {
element.as_widget().draw(
&tree.borrow().as_ref().unwrap().children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let tree = tree.state.downcast_ref::<Rc<RefCell<Option<Tree>>>>();
self.with_element(|element| {
element.as_widget().mouse_interaction(
&tree.borrow().as_ref().unwrap().children[0],
layout,
cursor,
viewport,
renderer,
)
})
}
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.rebuild_element_if_necessary();
let state = tree.state.downcast_mut::<Rc<RefCell<Option<Tree>>>>();
let tree = state.borrow_mut().take().unwrap();
let overlay = InnerBuilder {
instance: self,
tree,
types: PhantomData,
overlay_builder: |instance, tree| {
instance.state.get_mut().as_mut().unwrap().with_element_mut(
move |element| {
element
.as_mut()
.unwrap()
.as_widget_mut()
.overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
.map(|overlay| {
RefCell::new(overlay::Nested::new(overlay))
})
},
)
},
}
.build();
#[allow(clippy::redundant_closure_for_method_calls)]
if overlay.with_overlay(|overlay| overlay.is_some()) {
Some(overlay::Element::new(Box::new(OverlayInstance {
overlay: Some(Overlay(Some(overlay))), // Beautiful, I know
})))
} else {
let heads = overlay.into_heads();
// - You may not like it, but this is what peak performance looks like
// - TODO: Get rid of ouroboros, for good
// - What?!
*state.borrow_mut() = Some(heads.tree);
None
}
}
}
struct Overlay<'a, 'b, Message, Theme, Renderer, Event, S>(
Option<Inner<'a, 'b, Message, Theme, Renderer, Event, S>>,
);
impl<Message, Theme, Renderer, Event, S> Drop
for Overlay<'_, '_, Message, Theme, Renderer, Event, S>
{
fn drop(&mut self) {
if let Some(heads) = self.0.take().map(Inner::into_heads) {
*heads.instance.tree.borrow_mut().borrow_mut() = Some(heads.tree);
}
}
}
#[self_referencing]
struct Inner<'a, 'b, Message, Theme, Renderer, Event, S> {
instance: &'a mut Instance<'b, Message, Theme, Renderer, Event, S>,
tree: Tree,
types: PhantomData<(Message, Event, S)>,
#[borrows(mut instance, mut tree)]
#[not_covariant]
overlay: Option<RefCell<overlay::Nested<'this, Event, Theme, Renderer>>>,
}
struct OverlayInstance<'a, 'b, Message, Theme, Renderer, Event, S> {
overlay: Option<Overlay<'a, 'b, Message, Theme, Renderer, Event, S>>,
}
impl<Message, Theme, Renderer, Event, S>
OverlayInstance<'_, '_, Message, Theme, Renderer, Event, S>
{
fn with_overlay_maybe<T>(
&self,
f: impl FnOnce(&mut overlay::Nested<'_, Event, Theme, Renderer>) -> T,
) -> Option<T> {
self.overlay
.as_ref()
.unwrap()
.0
.as_ref()
.unwrap()
.with_overlay(|overlay| {
overlay.as_ref().map(|nested| (f)(&mut nested.borrow_mut()))
})
}
fn with_overlay_mut_maybe<T>(
&mut self,
f: impl FnOnce(&mut overlay::Nested<'_, Event, Theme, Renderer>) -> T,
) -> Option<T> {
self.overlay
.as_mut()
.unwrap()
.0
.as_mut()
.unwrap()
.with_overlay_mut(|overlay| {
overlay.as_mut().map(|nested| (f)(nested.get_mut()))
})
}
}
impl<Message, Theme, Renderer, Event, S>
overlay::Overlay<Message, Theme, Renderer>
for OverlayInstance<'_, '_, Message, Theme, Renderer, Event, S>
where
Renderer: core::Renderer,
S: 'static + Default,
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.with_overlay_maybe(|overlay| overlay.layout(renderer, bounds))
.unwrap_or_default()
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
let _ = self.with_overlay_maybe(|overlay| {
overlay.draw(renderer, theme, style, layout, cursor);
});
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
self.with_overlay_maybe(|overlay| {
overlay.mouse_interaction(layout, cursor, renderer)
})
.unwrap_or_default()
}
fn update(
&mut self,
event: &core::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let mut local_messages = Vec::new();
let mut local_shell = Shell::new(&mut local_messages);
let _ = self.with_overlay_mut_maybe(|overlay| {
overlay.update(
event,
layout,
cursor,
renderer,
clipboard,
&mut local_shell,
);
});
if local_shell.is_event_captured() {
shell.capture_event();
}
local_shell.revalidate_layout(|| shell.invalidate_layout());
shell.request_redraw_at(local_shell.redraw_request());
shell.request_input_method(local_shell.input_method());
if !local_messages.is_empty() {
let mut inner =
self.overlay.take().unwrap().0.take().unwrap().into_heads();
let mut heads = inner.instance.state.take().unwrap().into_heads();
for message in local_messages.into_iter().filter_map(|message| {
heads
.component
.update(inner.tree.state.downcast_mut(), message)
}) {
shell.publish(message);
}
*inner.instance.state.borrow_mut() = Some(
StateBuilder {
component: heads.component,
message: PhantomData,
state: PhantomData,
element_builder: |_| None,
}
.build(),
);
self.overlay = Some(Overlay(Some(
InnerBuilder {
instance: inner.instance,
tree: inner.tree,
types: PhantomData,
overlay_builder: |_, _| None,
}
.build(),
)));
shell.invalidate_layout();
}
}
}
-43
View File
@@ -1,43 +0,0 @@
use crate::core::{self, Element};
use crate::lazy::component;
use std::hash::Hash;
#[allow(deprecated)]
pub use crate::lazy::{Component, Lazy};
/// Creates a new [`Lazy`] widget with the given data `Dependency` and a
/// closure that can turn this data into a widget tree.
#[cfg(feature = "lazy")]
pub fn lazy<'a, Message, Theme, Renderer, Dependency, View>(
dependency: Dependency,
view: impl Fn(&Dependency) -> View + 'a,
) -> Lazy<'a, Message, Theme, Renderer, Dependency, View>
where
Dependency: Hash + 'a,
View: Into<Element<'static, Message, Theme, Renderer>>,
{
Lazy::new(dependency, view)
}
/// Turns an implementor of [`Component`] into an [`Element`] that can be
/// embedded in any application.
#[cfg(feature = "lazy")]
#[deprecated(
since = "0.13.0",
note = "components introduce encapsulated state and hamper the use of a single source of truth. \
Instead, leverage the Elm Architecture directly, or implement a custom widget"
)]
#[allow(deprecated)]
pub fn component<'a, C, Message, Theme, Renderer>(
component: C,
) -> Element<'a, Message, Theme, Renderer>
where
C: Component<Message, Theme, Renderer> + 'a,
C::State: 'static,
Message: 'a,
Theme: 'a,
Renderer: core::Renderer + 'a,
{
component::view(component)
}

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