diff --git a/.gitattributes b/.gitattributes index 43ce420c..0974a9e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -86,3 +86,6 @@ harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol + +# Preserve the pinned Iced dependency bytes and upstream manifest. +crates/vendor/iced_widget/** -text whitespace=cr-at-eol diff --git a/Cargo.lock b/Cargo.lock index 8719ac1e..c413b9d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1567,8 +1567,6 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" dependencies = [ "iced_renderer", "log", diff --git a/Cargo.toml b/Cargo.toml index db71c553..535841de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,11 @@ [workspace] members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"] +exclude = ["crates/vendor/iced_widget"] resolver = "2" +[patch.crates-io] +iced_widget = { path = "crates/vendor/iced_widget" } + [workspace.package] version = "0.2.0" edition = "2024" diff --git a/crates/oa-cli/src/lib.rs b/crates/oa-cli/src/lib.rs index d7b71537..0eaced56 100644 --- a/crates/oa-cli/src/lib.rs +++ b/crates/oa-cli/src/lib.rs @@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result = snapshot .diagnostics .iter() diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 24e3f891..f04e9552 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -3,6 +3,8 @@ mod semantic_source; #[path = "migration_v2/source.rs"] mod v2_source; +pub(crate) use crate::source_defaults::Mode as DeclarationFormat; +use crate::source_defaults::{self, Context as DefaultContext}; use crate::*; use serde_json::{Map, Value}; use std::{collections::BTreeMap, fmt}; @@ -645,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize { source.len() } -/// Canonical output uses persistent aliases and explicit identities. Typed -/// nested records are ordinary language fields; JSON documents are not embedded. +/// Canonical output uses persistent aliases and explicit identities, omitting +/// only values supplied by source defaults. Typed nested records are ordinary +/// language fields; JSON documents are not embedded. pub fn format(project: &Project) -> String { let mut out = std::format!( - "arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n", + "arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n", project.alias, quote(&project.name), project.id, - quote(&project.description) ); - for (_, declaration) in format_declarations(project) { + if !project.description.is_empty() { + out.push_str(&std::format!( + " description {};\n", + quote(&project.description) + )); + } + out.push('\n'); + for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) { out.push_str(&declaration); } out.push_str("}\n"); out } -/// The same typed declaration writer is used by flat and modular source output. -pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { +/// Shared typed writer for canonical output and lossless editing. Complete edit +/// comparisons retain defaults; emitted declarations and value fragments apply +/// only the omission rules valid in their selected source context. +pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> { let aliases = project .identities() .into_iter() @@ -675,6 +686,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { let mut fields = payload(&serde_json::to_value(&e.body).unwrap()); fields.insert("description".into(), Value::String(e.description.clone())); fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap()); + let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode); write_declaration( &mut out, &element_word(e.kind(), e.layer()), @@ -690,6 +702,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { let mut out = String::new(); let mut fields = payload(&serde_json::to_value(&r.body).unwrap()); fields.insert("description".into(), Value::String(r.description.clone())); + let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode); write_declaration( &mut out, &relation_word(r.kind()), @@ -705,6 +718,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { let mut out = String::new(); let mut fields = record_fields(d); fields.remove("nodes"); + let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode); write_declaration( &mut out, "diagram", @@ -719,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { let mut out = String::new(); let mut fields = record_fields(n); fields.insert("diagram".into(), Value::String(d.id.to_string())); + let fields = source_defaults::fields(DefaultContext::Node, &fields, mode); write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases); declarations.push((n.id, out)); } @@ -731,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { &a.alias, Some(&a.name), a.id, - &record_fields(a), + &source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode), &aliases, ); declarations.push((a.id, out)); @@ -744,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { &t.alias, Some(&t.name), t.id, - &record_fields(t), + &source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode), &aliases, ); declarations.push((t.id, out)); @@ -1742,7 +1757,9 @@ pub(crate) fn resolve_declarations( *viewpoint = ScenarioViewpoint::MixedLegacy; } let mut body = serde_json::to_value(initial).unwrap(); - let defaults = if legacy { + let defaults = if project.schema_version >= 7 { + source_defaults::parser_fields(DefaultContext::Element(kind)) + } else if legacy { crate::persistence::v3::legacy_defaults(kind) .and_then(|value| value.as_object().cloned()) .unwrap_or_else(|| payload(&body)) @@ -1799,7 +1816,12 @@ pub(crate) fn resolve_declarations( let mut body = serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil())) .unwrap(); - let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?; + let defaults = if project.schema_version >= 7 { + source_defaults::parser_fields(DefaultContext::Relation(kind)) + } else { + payload(&body) + }; + let merged = merge_fields(defaults, fields).map_err(&conversion_error)?; replace_payload(&mut body, merged); let body = serde_json::from_value(body) .map_err(|e| conversion_error(e.to_string()))?; @@ -1824,7 +1846,10 @@ pub(crate) fn resolve_declarations( } let defaults = serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()}); - let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + let defaults = if project.schema_version >= 7 { + source_defaults::parser_fields(DefaultContext::Diagram) + } else { defaults.as_object().unwrap().clone() }; + let mut record = merge_fields(defaults, fields) .map_err(&conversion_error)?; header(&mut record, d, true); record.insert("nodes".into(), serde_json::json!({})); @@ -1839,7 +1864,10 @@ pub(crate) fn resolve_declarations( return Err(conversion_error("Missing required field 'element'.".into())); } let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]}); - let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + let defaults = if project.schema_version >= 7 { + source_defaults::parser_fields(DefaultContext::Node) + } else { defaults.as_object().unwrap().clone() }; + let mut record = merge_fields(defaults, fields) .map_err(&conversion_error)?; header(&mut record, d, false); let node: NodePlacement = serde_json::from_value(Value::Object(record)) @@ -1851,7 +1879,10 @@ pub(crate) fn resolve_declarations( return Err(conversion_error("Missing required field 'owner'.".into())); } let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null}); - let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + let defaults = if project.schema_version >= 7 { + source_defaults::parser_fields(DefaultContext::Artifact) + } else { defaults.as_object().unwrap().clone() }; + let mut record = merge_fields(defaults, fields) .map_err(&conversion_error)?; header(&mut record, d, true); let artifact = serde_json::from_value(Value::Object(record)) @@ -1867,7 +1898,10 @@ pub(crate) fn resolve_declarations( } } let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null}); - let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + let defaults = if project.schema_version >= 7 { + source_defaults::parser_fields(DefaultContext::Trace) + } else { defaults.as_object().unwrap().clone() }; + let mut record = merge_fields(defaults, fields) .map_err(&conversion_error)?; header(&mut record, d, true); let trace = if project.schema_version < 5 { diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 7934fd65..43615cc9 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod references; pub mod routing; pub mod semantic; pub mod session; +mod source_defaults; pub mod source_files; pub mod source_project; pub mod topology_context; diff --git a/crates/oa-core/src/source_defaults.rs b/crates/oa-core/src/source_defaults.rs new file mode 100644 index 00000000..f9a4cf95 --- /dev/null +++ b/crates/oa-core/src/source_defaults.rs @@ -0,0 +1,382 @@ +//! 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 { + 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, + mode: Mode, +) -> Map { + 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) { + 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 { + 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 { + 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 { + 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) { + 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) { + 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) { + 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, + key: &str, + tag: &str, +) -> Option<&'a mut Map> { + 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, 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, defaults: &Map) { + 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 { + match value { + Value::Object(fields) => fields, + _ => unreachable!("literal source default is an object"), + } +} + +#[cfg(test)] +#[path = "source_defaults_tests.rs"] +mod tests; diff --git a/crates/oa-core/src/source_defaults_tests.rs b/crates/oa-core/src/source_defaults_tests.rs new file mode 100644 index 00000000..049495e1 --- /dev/null +++ b/crates/oa-core/src/source_defaults_tests.rs @@ -0,0 +1,718 @@ +//! Independent literal oracles for the production source-default policy. +use super::{self as policy, Context, Mode}; +use crate::{Kind, RelationKind}; +use serde_json::{Value, json}; + +#[test] +fn complete_comparison_keeps_populated_serializer_skip_values() { + for (context, input) in [ + ( + Context::Element(Kind::Occurrence), + json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}), + ), + ( + Context::Node, + json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}), + ), + ( + Context::Trace, + json!({"baseline":{"semantic":{"historical":"payload"}}}), + ), + ] { + assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input); + } +} + +#[test] +fn compact_value_fragments_keep_selected_constructor_requirements() { + for (context, input, expected) in [ + ( + Context::Element(Kind::DataType), + json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}), + json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}), + ), + ( + Context::Element(Kind::Event), + json!({"body":{"Signal":{"items":[]}}}), + json!({"body":{"Signal":{"items":[]}}}), + ), + ( + Context::Element(Kind::Occurrence), + json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}), + json!({"body":{"Exchange":{"operation":{}}},"precondition":""}), + ), + ( + Context::Element(Kind::Occurrence), + json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}), + json!({"body":{"ScenarioUse":{"bindings":[]}}}), + ), + ] { + assert_eq!(rendered(context, &input, Mode::CompactValues), expected); + } +} + +#[test] +fn source_scenario_default_is_independent_of_graphical_creation() { + use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language}; + let project = language::parse( + "arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }", + ) + .unwrap(); + for element in project.elements.values() { + assert!(matches!( + element.body.shared(), + Some(SharedBody::Scenario { + viewpoint: ScenarioViewpoint::MixedLegacy, + .. + }) + )); + assert!(!matches!( + ElementBody::new(Kind::Scenario, element.layer()) + .unwrap() + .shared(), + Some(SharedBody::Scenario { + viewpoint: ScenarioViewpoint::MixedLegacy, + .. + }) + )); + } + let source = language::format(&project); + assert!(!source.contains("description")); + assert!(!source.contains("parent")); + assert!(!source.contains("viewpoint")); + assert_eq!(language::parse(&source).unwrap(), project); + + let mut typed = project; + for element in typed.elements.values_mut() { + let viewpoint = if element.layer() == Layer::Oa { + ScenarioViewpoint::OperationalActivity + } else { + ScenarioViewpoint::Functional + }; + *element.body.shared_mut().unwrap() = SharedBody::Scenario { + viewpoint, + context: None, + precondition: String::new(), + postcondition: String::new(), + steps: vec![], + }; + } + let source = language::format(&typed); + assert!(source.contains("viewpoint OperationalActivity;")); + assert!(source.contains("viewpoint Functional;")); + assert_eq!(language::parse(&source).unwrap(), typed); +} + +#[test] +fn shared_parser_defaults_preserve_required_fields_and_kind_checks() { + for (declaration, message) in [ + ( + "oa_port p0 \"Port\" {}", + "Missing required field 'contract'", + ), + ( + "allocation r \"R\" { source @owner; }", + "Missing required field 'target'", + ), + ( + "functional_exchange r \"R\" { source @owner; target @work; }", + "Missing required field 'source_port'", + ), + ( + "contextual_link r \"R\" {}", + "Missing required field 'owner'", + ), + ("node n { diagram @d; }", "Missing required field 'element'"), + ("artifact a \"A\" {}", "Missing required field 'owner'"), + ("trace t \"T\" {}", "Missing required field 'oa'"), + ( + "oa_state s \"S\" { kind Initial; }", + "Declaration kind disagrees", + ), + ( + "oa_activity a \"A\" { unknown true; }", + "Unknown field 'unknown'", + ), + ( + "oa_data_type t \"T\" { definition { String {}; }; }", + "minimum_length", + ), + ( + "oa_occurrence o \"O\" { body { ScenarioUse {}; }; }", + "bindings", + ), + ] { + let source = format!( + "arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}" + ); + let errors = crate::language::parse(&source).unwrap_err(); + assert!( + errors.iter().any(|error| error.message.contains(message)), + "{declaration}: {errors:?}" + ); + } +} + +fn rendered(context: Context, input: &Value, mode: Mode) -> Value { + Value::Object(policy::fields(context, input.as_object().unwrap(), mode)) +} + +fn compact(context: Context, input: Value, expected: Value) { + let original = input.clone(); + let result = rendered(context, &input, Mode::Compact); + assert_eq!(result, expected, "context {context:?}"); + assert_eq!(input, original, "the input remains immutable"); + assert_eq!( + rendered(context, &result, Mode::Compact), + result, + "compact policy is idempotent" + ); +} + +#[test] +fn complete_comparison_restores_only_the_three_known_skips() { + let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}}); + let mut complete = exchange.clone(); + complete["body"]["Exchange"]["operation"] = Value::Null; + assert_eq!( + rendered( + Context::Element(Kind::Occurrence), + &exchange, + Mode::CompleteForEdit + ), + complete + ); + assert_eq!( + rendered( + Context::Node, + &json!({"element":"e","diagram":"d"}), + Mode::CompleteForEdit + ), + json!({"element":"e","diagram":"d","port_anchors":[]}) + ); + assert_eq!( + rendered( + Context::Trace, + &json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}), + Mode::CompleteForEdit + ), + json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}}) + ); + for input in [ + json!({"body":{"Action":{"action":null,"lifeline":null}}}), + json!({"body":{"State":{"state":null,"lifeline":null}}}), + ] { + assert_eq!( + rendered( + Context::Element(Kind::Occurrence), + &input, + Mode::CompleteForEdit + ), + input + ); + } + for input in [ + json!({"baseline":null}), + json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}), + ] { + assert_eq!( + rendered(Context::Trace, &input, Mode::CompleteForEdit), + input + ); + } +} + +#[test] +fn every_element_omits_only_empty_common_defaults() { + for kind in Kind::ALL { + // Future/unknown payloads are preservation sentinels, not admitted source. + compact( + Context::Element(kind), + json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}), + json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}), + ); + compact( + Context::Element(kind), + json!({"description":" \n","parent":"owner"}), + json!({"description":" \n","parent":"owner"}), + ); + } +} + +#[test] +fn ordinary_element_defaults_are_context_specific() { + for kind in [Kind::Process, Kind::FunctionalChain] { + compact(Context::Element(kind), json!({"steps":[]}), json!({})); + compact( + Context::Element(kind), + json!({"steps":["second","first"]}), + json!({"steps":["second","first"]}), + ); + } + compact( + Context::Element(Kind::ExchangeItem), + json!({"kind":"Information","mechanism":"Unspecified","supertype":null}), + json!({}), + ); + compact( + Context::Element(Kind::ExchangeItem), + json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}), + json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}), + ); + compact( + Context::Element(Kind::ExchangeCategory), + json!({"family":"Work","exchanges":[]}), + json!({}), + ); + compact( + Context::Element(Kind::ExchangeCategory), + json!({"family":"Participant","exchanges":["x"]}), + json!({"family":"Participant","exchanges":["x"]}), + ); + compact( + Context::Element(Kind::Interface), + json!({"supertype":null}), + json!({}), + ); + compact( + Context::Element(Kind::Lifeline), + json!({"represented":null}), + json!({}), + ); + compact( + Context::Element(Kind::Constraint), + json!({"expression":"","constrained":[]}), + json!({}), + ); +} + +#[test] +fn scenario_source_default_never_uses_the_graphical_viewpoint_default() { + compact( + Context::Element(Kind::Scenario), + json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}), + json!({}), + ); + for viewpoint in [ + "OperationalActivity", + "Functional", + "ParticipantInteraction", + "BoundaryInterface", + ] { + compact( + Context::Element(Kind::Scenario), + json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}), + json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}), + ); + } +} + +#[test] +fn each_state_header_supplies_its_own_kind() { + for (kind, name) in [ + (Kind::State, "State"), + (Kind::Mode, "Mode"), + (Kind::Initial, "Initial"), + (Kind::Final, "Final"), + (Kind::Choice, "Choice"), + (Kind::Junction, "Junction"), + (Kind::Fork, "Fork"), + (Kind::Join, "Join"), + (Kind::ShallowHistory, "ShallowHistory"), + (Kind::DeepHistory, "DeepHistory"), + (Kind::EntryPoint, "EntryPoint"), + (Kind::ExitPoint, "ExitPoint"), + (Kind::Terminate, "Terminate"), + ] { + compact( + Context::Element(kind), + json!({"kind":name,"entry":"","exit":"","do_activity":""}), + json!({}), + ); + } + // Invalid mismatches must not be silently normalized by the writer policy. + compact( + Context::Element(Kind::Mode), + json!({"kind":"State","entry":"work"}), + json!({"kind":"State","entry":"work"}), + ); +} + +#[test] +fn data_field_unbounded_and_present_empty_values_are_not_defaults() { + compact( + Context::Element(Kind::DataField), + json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}), + json!({}), + ); + compact( + Context::Element(Kind::DataField), + json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}), + json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}), + ); + for value in [ + json!({"Boolean":false}), + json!({"Integer":0}), + json!({"Real":0.0}), + json!({"String":""}), + ] { + compact( + Context::Element(Kind::DataField), + json!({"default":[value.clone()]}), + json!({"default":[value]}), + ); + } +} + +#[test] +fn data_definition_whole_default_and_selected_required_fields_differ() { + compact( + Context::Element(Kind::DataType), + json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}), + json!({}), + ); + for (input, expected) in [ + ( + json!({"Integer":{"minimum":null,"maximum":null}}), + json!({"Integer":{}}), + ), + ( + json!({"Real":{"minimum":null,"maximum":2.5}}), + json!({"Real":{"maximum":2.5}}), + ), + ( + json!({"String":{"minimum_length":4,"maximum_length":null}}), + json!({"String":{"minimum_length":4}}), + ), + (json!({"Record":{"supertype":null}}), json!({"Record":{}})), + ( + json!({"Enumeration":{"literals":[]}}), + json!({"Enumeration":{"literals":[]}}), + ), + ] { + compact( + Context::Element(Kind::DataType), + json!({"definition":input}), + json!({"definition":expected}), + ); + } + compact( + Context::Element(Kind::DataType), + json!({"definition":"Boolean","default":{"Boolean":false}}), + json!({"definition":"Boolean","default":{"Boolean":false}}), + ); +} + +#[test] +fn port_contract_and_its_required_payload_stay_explicit() { + compact( + Context::Element(Kind::Port), + json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}), + json!({"contract":"Legacy","legacy":{"direction":"Out"}}), + ); + for contract in [ + json!({"FunctionInput":{"incoming_items":[]}}), + json!({"FunctionOutput":{"outgoing_items":[]}}), + json!({"Component":{"kind":"Standard","orientation":"Unset"}}), + ] { + compact( + Context::Element(Kind::Port), + json!({"contract":contract.clone(),"legacy":null}), + json!({"contract":contract}), + ); + } +} + +#[test] +fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() { + compact( + Context::Element(Kind::Occurrence), + json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}), + json!({}), + ); + for (input, expected) in [ + ( + json!({"Action":{"action":null,"lifeline":null}}), + json!({"Action":{}}), + ), + ( + json!({"State":{"state":null,"lifeline":null}}), + json!({"State":{}}), + ), + ( + json!({"SequenceReference":{"sequence":null}}), + json!({"SequenceReference":{}}), + ), + ( + json!({"ScenarioUse":{"scenario":null,"bindings":[]}}), + json!({"ScenarioUse":{"bindings":[]}}), + ), + ( + json!({"Branch":{"guard":"","occurrences":[]}}), + json!({"Branch":{"guard":"","occurrences":[]}}), + ), + ( + json!({"Fragment":{"operator":"Parallel","branches":[]}}), + json!({"Fragment":{"operator":"Parallel","branches":[]}}), + ), + ( + json!({"Control":{"kind":"Choice"}}), + json!({"Control":{"kind":"Choice"}}), + ), + ] { + compact( + Context::Element(Kind::Occurrence), + json!({"body":input}), + json!({"body":expected}), + ); + } +} + +#[test] +fn unresolved_operation_and_required_message_value_are_preserved() { + compact( + Context::Element(Kind::Occurrence), + json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}), + json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}), + ); + compact( + Context::Element(Kind::Occurrence), + json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}), + json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}), + ); +} + +#[test] +fn event_body_whole_default_does_not_prune_selected_required_fields() { + compact( + Context::Element(Kind::Event), + json!({"body":{"Signal":{"items":[]}}}), + json!({}), + ); + for body in [ + json!({"Signal":{"items":["i"]}}), + json!({"Time":{"kind":"After","expression":""}}), + json!({"Change":{"expression":""}}), + ] { + compact( + Context::Element(Kind::Event), + json!({"body":body.clone()}), + json!({"body":body}), + ); + } +} + +#[test] +fn relation_required_endpoints_and_contextual_payloads_remain() { + for kind in RelationKind::ALL { + compact( + Context::Relation(kind), + json!({"description":"","source":"s","target":"t","future":[]}), + json!({"source":"s","target":"t","future":[]}), + ); + } + compact( + Context::Relation(RelationKind::FunctionalExchange), + json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}), + json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}), + ); + let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}}); + compact( + Context::Relation(RelationKind::ContextualLink), + contextual.clone(), + contextual, + ); +} + +#[test] +fn relation_defaults_preserve_contextual_orientation_and_multiplicity() { + for (kind, orientation) in [ + (RelationKind::BoundaryExchange, true), + (RelationKind::CommunicationMean, false), + ] { + compact( + Context::Relation(kind), + json!({"oriented":orientation,"items":[]}), + json!({}), + ); + compact( + Context::Relation(kind), + json!({"oriented":!orientation}), + json!({"oriented":!orientation}), + ); + } + compact( + Context::Relation(RelationKind::DataAssociation), + json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}), + json!({}), + ); + compact( + Context::Relation(RelationKind::DataAssociation), + json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}), + json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}), + ); + compact( + Context::Relation(RelationKind::InterfaceItemAllocation), + json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}), + json!({}), + ); + compact( + Context::Relation(RelationKind::CapabilityExtend), + json!({"condition":""}), + json!({}), + ); + compact( + Context::Relation(RelationKind::SequenceLink), + json!({"guard":""}), + json!({}), + ); + compact( + Context::Relation(RelationKind::StateAction), + json!({"phase":"Do"}), + json!({}), + ); + compact( + Context::Relation(RelationKind::StateTransition), + json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}), + json!({}), + ); + compact( + Context::Relation(RelationKind::StateTransition), + json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}), + json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}), + ); +} + +#[test] +fn diagram_options_remove_only_unrestricted_defaults() { + let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"}); + compact( + Context::Diagram, + json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}), + json!({}), + ); + compact( + Context::Diagram, + json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}), + json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}), + ); + compact( + Context::Diagram, + json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}), + json!({"routes":[{"edge":"e"}]}), + ); +} + +#[test] +fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() { + compact( + Context::Node, + json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}), + json!({"diagram":"d","element":"e"}), + ); + for position in [ + json!({"x":-0.0,"y":0.0}), + json!({"x":0.0,"y":-0.0}), + json!({"x":1.0,"y":0.0}), + ] { + let result = rendered( + Context::Node, + &json!({"position":position.clone()}), + Mode::Compact, + ); + assert_eq!(result, json!({"position":position.clone()})); + for coordinate in ["x", "y"] { + assert_eq!( + result["position"][coordinate].as_f64().unwrap().to_bits(), + position[coordinate].as_f64().unwrap().to_bits(), + "coordinate sign is part of fidelity" + ); + } + } + compact( + Context::Node, + json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}), + json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}), + ); +} + +#[test] +fn artifact_present_empty_strings_and_trace_history_stay_distinct() { + compact( + Context::Artifact, + json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}), + json!({"owner":"o"}), + ); + compact( + Context::Artifact, + json!({"owner":"o","relation":"Implements","revision":"","fragment":""}), + json!({"owner":"o","relation":"Implements","revision":"","fragment":""}), + ); + compact( + Context::Trace, + json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}), + json!({"oa":"o","sa":"s"}), + ); + compact( + Context::Trace, + json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}), + json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}), + ); + let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}}); + compact( + Context::Trace, + json!({"kind":"Manual","baseline":baseline.clone()}), + json!({"kind":"Manual","baseline":baseline}), + ); +} + +#[test] +fn nested_future_fields_prevent_whole_default_removal() { + // These sentinels exercise preservation, not current parser admission of + // unknown fields. Only known slots may lose their redundant defaults. + compact( + Context::Element(Kind::DataType), + json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}), + json!({"definition":{"String":{"minimum_length":0,"future":[]}}}), + ); + compact( + Context::Element(Kind::Occurrence), + json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}), + json!({"body":{"Exchange":{"future":false}}}), + ); + compact( + Context::Diagram, + json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}), + json!({"view_options":{"future":null}}), + ); +} + +#[test] +fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() { + for definition in [ + json!({"Future":{"minimum":null,"maximum":null}}), + json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}), + ] { + compact( + Context::Element(Kind::DataType), + json!({"definition":definition.clone()}), + json!({"definition":definition}), + ); + } + for body in [ + json!({"Future":{"operation":null,"values":[]}}), + json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}), + ] { + let fields = json!({"body":body}); + assert_eq!( + rendered( + Context::Element(Kind::Occurrence), + &fields, + Mode::CompleteForEdit + ), + fields, + "do not invent a selected Exchange constructor in an unknown wrapper", + ); + compact(Context::Element(Kind::Occurrence), fields.clone(), fields); + } +} diff --git a/crates/oa-core/src/source_files.rs b/crates/oa-core/src/source_files.rs index 524ec44e..319416b5 100644 --- a/crates/oa-core/src/source_files.rs +++ b/crates/oa-core/src/source_files.rs @@ -40,6 +40,7 @@ pub enum DiskExpectation { /// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`; /// use `diagnostics` to explain files that discovery could not read or parse. +/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization. /// Expectations always describe disk bytes before UUID stabilization. #[derive(Debug, Clone)] pub struct DiskProject { @@ -526,10 +527,11 @@ pub fn discover_missing( result.expectations.keys().chain(result.bundle.files.keys()), )?; if result.diagnostics.is_empty() { - match compile_bundle(&result.reachable_bundle(), limits) { - Ok(compiled) => result.diagnostics = compiled.diagnostics, - Err(diagnostics) => result.diagnostics = diagnostics, - } + // Discovery retains raw editor bytes. Compilation may insert UUIDs or + // rewrite older syntax, so its returned locations belong to a different + // bundle. Lint still performs semantic migration and model validation; + // Open/Apply separately compile and install the rewritten source. + result.diagnostics = lint_bundle(&result.reachable_bundle(), limits); } let pending = journal_path(&result.root, &result.bundle.entry); if fs::symlink_metadata(&pending).is_ok() { diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 4d3e377f..22575d07 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec SourceBundle { let ownership = default_ownership(project); let aliases: BTreeMap<_, _> = project.identities().into_iter().collect(); - let mut declarations = language::format_declarations(project); + let mut declarations = + language::format_declarations(project, language::DeclarationFormat::Compact); declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases)); let mut files: BTreeMap = BTreeMap::new(); for (id, declaration) in declarations { @@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle { .or_default() .push_str(&unindent(&declaration)); } - let mut entry = format!( - "arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n", - project_header(project), - quote(&project.description) - ); + let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project)); + if !project.description.is_empty() { + entry.push_str(&format!( + " description {};\n", + quote(&project.description) + )); + } for path in files.keys() { entry.push_str(&format!(" include {};\n", quote(path))); } @@ -850,8 +853,25 @@ pub fn update_bundle_with_identity_map( )]); } let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect(); - let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect(); - let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect(); + // Comparison must include defaults even when persistence serialization or + // compact output omits them. Otherwise an unrelated payload edit erases + // unchanged explicit fields from the authored document. + let before: BTreeMap<_, _> = + language::format_declarations(old, language::DeclarationFormat::CompleteForEdit) + .into_iter() + .collect(); + let after: BTreeMap<_, _> = + language::format_declarations(project, language::DeclarationFormat::CompleteForEdit) + .into_iter() + .collect(); + let compact: BTreeMap<_, _> = + language::format_declarations(project, language::DeclarationFormat::Compact) + .into_iter() + .collect(); + let compact_values: BTreeMap<_, _> = + language::format_declarations(project, language::DeclarationFormat::CompactValues) + .into_iter() + .collect(); let old_aliases: BTreeMap<_, _> = old .identities() .into_iter() @@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map( }; let mut previous = parse(old_text)?; let mut next = parse(new_text)?; + let next_id = identities[&id]; + let value_text = &compact_values[&next_id]; + let insertion = InsertionSyntax::new( + &next.fields, + &parse(value_text)?.fields, + value_text, + &parse(&compact[&next_id])?.fields, + ); + let old_kind = previous.header["kind"]; + let new_kind = next.header["kind"]; + if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end] + && let language::EditKind::Record(actual_fields) = &actual.fields.kind + && let language::EditKind::Record(previous_fields) = &mut previous.fields.kind + { + // An implicit value is supplied by its declaration context. + // After a kind change, an unchanged value may need explicit + // syntax (e.g. BoundaryExchange true -> CommunicationMean true). + // Reconsider only absent fields; authored explicit values still + // compare against their complete old values and keep trivia. + previous_fields.retain(|key, _| actual_fields.contains_key(key)); + } if actual.lexical_owner.is_some() && let language::EditKind::Record(fields) = &actual.fields.kind && !fields.contains_key("parent") @@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map( source, before: old_text, after: new_text, + insertion: Some(&insertion), old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, @@ -953,6 +995,7 @@ pub fn update_bundle_with_identity_map( source, before: &before, after: &after, + insertion: None, old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, @@ -1011,7 +1054,7 @@ pub fn update_bundle_with_identity_map( project, identities, &original_order, - &after, + &compact, limits, )?; let result = compile_bundle(&bundle, limits)?; @@ -1119,10 +1162,73 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String { output } +// Value fragments retain required payload fields even when the enclosing +// declaration field is itself omittable. For example, a selected String type +// needs minimum_length; an Event Signal needs items. The complete tree remains +// the comparison authority; this parallel tree is only an insertion template. +struct InsertionSyntax<'a> { + source: &'a str, + values: BTreeMap, + omitted_fields: BTreeSet, +} +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, new_aliases: &'a BTreeMap, // Source3 migration only adds path qualification; the existing field identity @@ -1131,6 +1237,16 @@ struct SyntaxUpdate<'a> { identity_origins: Option<&'a BTreeMap>, } impl SyntaxUpdate<'_> { + fn inserted(&self, value: &language::EditValue) -> &str { + self.insertion + .and_then(|syntax| { + syntax + .values + .get(&value.span.start) + .map(|span| &syntax.source[span.start..span.end]) + }) + .unwrap_or(&self.after[value.span.start..value.span.end]) + } fn header( &self, actual: &BTreeMap<&str, Span>, @@ -1243,7 +1359,7 @@ impl SyntaxUpdate<'_> { _ => { erase_syntax(self.source, actual.span, edits); let text = inserted_value( - &self.after[after.span.start..after.span.end], + self.inserted(after), indent_at(self.source, actual.span.start), self.source, ); @@ -1308,6 +1424,9 @@ impl SyntaxUpdate<'_> { .iter() .filter(|(key, field)| { !actual.contains_key(*key) + && self.insertion.is_none_or(|syntax| { + !syntax.omitted_fields.contains(&field.value.span.start) + }) && before .and_then(|fields| fields.get(*key)) .is_none_or(|old| !self.same(&old.value, &field.value)) @@ -1326,11 +1445,7 @@ impl SyntaxUpdate<'_> { .unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start))); let mut text = String::new(); for (key, field) in added { - let value = inserted_value( - &self.after[field.value.span.start..field.value.span.end], - &indent, - self.source, - ); + let value = inserted_value(self.inserted(&field.value), &indent, self.source); if multiline { text.push_str(&indent); text.push_str(&format!("{key} {value};{}", newline(self.source))); @@ -1450,11 +1565,7 @@ impl SyntaxUpdate<'_> { } else { text.push(' '); } - text.push_str(&inserted_value( - &self.after[next.span.start..next.span.end], - &indent, - self.source, - )); + text.push_str(&inserted_value(self.inserted(next), &indent, self.source)); suffix = ""; } if i + 1 < after.len() { diff --git a/crates/oa-core/src/source_project/source_migration.rs b/crates/oa-core/src/source_project/source_migration.rs index 05ce86d1..54d50df9 100644 --- a/crates/oa-core/src/source_project/source_migration.rs +++ b/crates/oa-core/src/source_project/source_migration.rs @@ -11,7 +11,10 @@ pub(super) fn upgrade( .as_ref() .expect("an explicit source migration report"); let project = ¤t.decoded.project; - let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect(); + let canonical: BTreeMap<_, _> = + language::format_declarations(project, language::DeclarationFormat::CompleteForEdit) + .into_iter() + .collect(); let changed: BTreeSet<_> = report .semantic_defaults .iter() @@ -59,6 +62,7 @@ pub(super) fn upgrade( source, before: source, after, + insertion: None, old_aliases: &aliases, new_aliases: &aliases, legacy_value_paths: true, diff --git a/crates/oa-core/tests/default_omission_admission.rs b/crates/oa-core/tests/default_omission_admission.rs new file mode 100644 index 00000000..d7a69251 --- /dev/null +++ b/crates/oa-core/tests/default_omission_admission.rs @@ -0,0 +1,344 @@ +//! Existing source-7 nested omission compatibility, independently of formatting. +//! +//! Selected constructors use their own field contracts. These handwritten +//! sources and literal models distinguish nullable optional slots from required +//! fields; they do not prescribe compact canonical output or broaden admission. + +use oa_core::{source_project::*, *}; +use std::collections::BTreeMap; + +fn id(number: u8) -> Id { + Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap() +} + +fn bundle(declarations: &str) -> SourceBundle { + let source = format!( + "arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n" + ); + SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), source)]), + } +} + +fn element(number: u8, alias: &str, name: &str, parent: Option, 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) -> 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", + ); + } +} diff --git a/crates/oa-core/tests/default_omission_fidelity.rs b/crates/oa-core/tests/default_omission_fidelity.rs new file mode 100644 index 00000000..65b99bdb --- /dev/null +++ b/crates/oa-core/tests/default_omission_fidelity.rs @@ -0,0 +1,445 @@ +//! Public flat/modular formatter fidelity over frozen input and bounded mutations. +//! Legacy fixture migration uses the public parser. The original adapter tests +//! and exact inputs remain archived at source commit 2a64344; this suite exercises +//! production formatters, not that frozen experiment or lossless model edits. + +use oa_core::{language, source_project::*, *}; +use std::collections::BTreeMap; + +const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia"); +const KEYED: &str = + include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia"); +const STATES: &str = + include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia"); +const DENSE: &str = + include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia"); +const OPERATIONS: &str = include_str!( + "../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia" +); + +fn read(source: &str) -> Project { + language::parse(source).unwrap() +} + +fn identity(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} + +fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) { + if project.description.is_empty() { + assert!(compiled.index.project_description.is_none()); + } + let descriptions = project + .elements + .values() + .map(|e| (e.id, &e.description)) + .chain(project.relations.values().map(|r| (r.id, &r.description))) + .chain(project.diagrams.values().map(|d| (d.id, &d.description))) + .chain( + project + .artifact_links + .values() + .map(|a| (a.id, &a.description)), + ) + .chain(project.traces.values().map(|t| (t.id, &t.description))); + for (id, description) in descriptions { + if description.is_empty() { + assert!( + !compiled + .index + .fields + .contains_key(&(id, "description".into())) + ); + } + } + for element in project.elements.values().filter(|e| e.parent.is_none()) { + assert!( + !compiled + .index + .fields + .contains_key(&(element.id, "parent".into())) + ); + } +} + +fn assert_fidelity(label: &str, project: &Project) -> (Project, String) { + let flat = language::format(project); + let compact_model = read(&flat); + assert_eq!( + compact_model, *project, + "{label}: every persistent field survives" + ); + + let input = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), flat.clone())]), + }; + let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap(); + assert_eq!( + compiled.decoded.project, *project, + "{label}: source-project compile" + ); + assert_eq!( + compiled.bundle, input, + "{label}: compact identities/source are stable" + ); + + assert_common_omissions(project, &compiled); + assert_eq!( + language::format(&compact_model), + flat, + "{label}: flat idempotence" + ); + + let modular = format_bundle(project); + assert!( + modular.files.len() > 1, + "{label}: exercise generated includes" + ); + let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap(); + assert_eq!( + modular_compiled.decoded.project, *project, + "{label}: complete modular model" + ); + assert_eq!( + modular_compiled.bundle, modular, + "{label}: modular source/IDs are stable" + ); + assert_common_omissions(project, &modular_compiled); + assert_eq!( + format_bundle(&compact_model), + modular, + "{label}: flat-to-modular determinism" + ); + assert_eq!( + format_bundle(&modular_compiled.decoded.project), + modular, + "{label}: modular idempotence" + ); + + // Pinned measured compact bytes from the published fidelity-1 stderr.log at + // 2a64344, not a comparison against today's (now compact) canonical writer. + // A small header-layout allowance avoids enforcing incidental blank lines. + let measured_compact_bytes = match label { + "coastal-source3" => 28_465, + "keyed-source5-history" => 12_066, + "state-actions-source5" => 6_673, + "dense40-source6" => 19_342, + "boundary-operations-source6" => 3_660, + "state-negative-zero" => 6_672, + "keyed-present-empty-default" => 12_108, + "coastal-present-empty-artifact-strings" => 28_483, + "boundary-selected-and-unresolved-operations" => 3_806, + _ => panic!("missing independent compact measurement for {label}"), + }; + assert!( + flat.len() <= measured_compact_bytes + 16, + "{label}: {} bytes exceeds compact evidence budget {}", + flat.len(), + measured_compact_bytes + 16 + ); + (compact_model, flat) +} + +#[test] +fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() { + assert!(COASTAL.starts_with("arcadia 3;")); + let project = read(COASTAL); + assert!(project.elements.values().any(|e| e.layer() == Layer::Oa)); + assert!(project.elements.values().any(|e| e.layer() == Layer::Sa)); + assert!( + project + .elements + .values() + .any(|e| matches!(e.shared(), Some(SharedBody::Port { .. }))) + ); + assert!(project.elements.values().any( + |e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty()) + )); + assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa + && d.projection == ProjectionKind::FunctionalDataflow + && !d.nodes.is_empty())); + assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa + && d.projection == ProjectionKind::Allocation + && !d.nodes.is_empty())); + assert!(!project.artifact_links.is_empty()); + assert!(project.traces.values().any(|t| t.baseline.is_some())); + assert_fidelity("coastal-source3", &project); +} + +#[test] +fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() { + let project = read(KEYED); + for layer in [Layer::Oa, Layer::Sa] { + assert!(project.elements.values().any(|e| e.layer() == layer + && matches!( + e.shared(), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Record { .. }, + .. + }) + ))); + assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty()))); + } + assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty())))); + let facets: Vec<_> = project + .traces + .values() + .filter_map(|t| t.baseline.as_ref()?.semantic.as_ref()) + .flat_map(|baseline| &baseline.facets) + .collect(); + assert!( + facets + .iter() + .any(|facet| matches!(facet, semantic::FacetReview::Binding { .. })) + ); + assert!( + facets + .iter() + .any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. })) + ); + assert!( + KEYED.contains("uuid \""), + "fixture contains historical identity atoms" + ); + let (_, source) = assert_fidelity("keyed-source5-history", &project); + assert!( + source.contains("uuid \""), + "historical atoms remain historical" + ); +} + +#[test] +fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() { + let project = read(STATES); + let machine = identity(&project, "operating"); + let view = &project.diagrams[&identity(&project, "state_view")]; + assert_eq!(view.projection, ProjectionKind::StateMachine { machine }); + assert!(!view.nodes.is_empty()); + for phase in [ + StateActionKind::Entry, + StateActionKind::Do, + StateActionKind::Exit, + ] { + assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase))); + } + assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty()))); + assert!( + matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts") + ); + assert_fidelity("state-actions-source5", &project); +} + +#[test] +fn forty_exchange_diagram_with_categories_and_processes_roundtrip() { + let project = read(DENSE); + assert_eq!( + project + .relations + .values() + .filter(|r| r.kind() == RelationKind::Interaction) + .count(), + 40 + ); + assert_eq!( + project + .elements + .values() + .filter(|e| e.kind() == Kind::ExchangeCategory) + .count(), + 5 + ); + assert_eq!( + project + .elements + .values() + .filter(|e| e.kind() == Kind::Process) + .count(), + 3 + ); + let view = &project.diagrams[&identity(&project, "forty_exchange_view")]; + assert_eq!(view.projection, ProjectionKind::FunctionalDataflow); + assert_eq!(view.nodes.len(), 2); + assert!(view.routes.is_empty()); + assert_eq!( + view.view_options.relation_kinds, + view_options::RelationKinds::All + ); + assert_fidelity("dense40-source6", &project); +} + +#[test] +fn component_ports_interface_operations_and_boundary_calls_roundtrip() { + let project = read(OPERATIONS); + assert_eq!( + project + .relations + .values() + .filter(|r| r.kind() == RelationKind::InterfaceItemAllocation) + .count(), + 2 + ); + assert_eq!( + project + .elements + .values() + .filter(|e| matches!( + e.shared(), + Some(SharedBody::Port { + contract: PortContract::Component { + kind: ComponentPortKind::Standard, + .. + }, + .. + }) + )) + .count(), + 2 + ); + assert!(project.elements.values().any(|e| matches!( + e.shared(), + Some(SharedBody::ExchangeItem { + mechanism: ExchangeMechanism::Operation, + .. + }) + ))); + assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2))); + assert_eq!( + project + .elements + .values() + .filter(|e| matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + operation: None, + .. + }, + .. + }) + )) + .count(), + 2 + ); + assert_fidelity("boundary-operations-source6", &project); +} + +#[test] +fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() { + let mut project = read(STATES); + let diagram = identity(&project, "state_view"); + let node = identity(&project, "view_machine"); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .position = Position { x: -0.0, y: 0.0 }; + let (decoded, flat) = assert_fidelity("state-negative-zero", &project); + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), flat)]), + }; + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap(); + // Project/Position PartialEq alone equates the signs of zero. Check bits in + // each independently decoded representation, including source-project compile. + for actual in [ + &decoded, + &compiled.decoded.project, + &modular.decoded.project, + ] { + let position = &actual.diagrams[&diagram].nodes[&node].position; + assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits()); + assert_eq!(position.y.to_bits(), 0.0_f32.to_bits()); + } +} + +#[test] +fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() { + let mut project = read(KEYED); + let field_id = identity(&project, "oa_navigation_latitude"); + let Some(SharedBody::DataField { + minimum, + maximum, + default, + storage, + .. + }) = project.elements.get_mut(&field_id).unwrap().shared_mut() + else { + panic!("fixture must contain the claimed DataField"); + }; + assert_eq!(*minimum, 0); + assert_eq!(*maximum, Some(1)); + assert_eq!(*default, None); + assert_eq!(*storage, FieldStorage::Value); + *maximum = None; + *default = Some(vec![]); + let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project); + assert!( + matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty()) + ); +} + +#[test] +fn present_empty_artifact_revision_and_fragment_survive() { + let mut project = read(COASTAL); + let artifact_id = identity(&project, "console_enclosure_cad"); + let artifact = project.artifact_links.get_mut(&artifact_id).unwrap(); + artifact.revision = Some(String::new()); + artifact.fragment = Some(String::new()); + let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project); + assert_eq!( + decoded.artifact_links[&artifact_id].revision.as_deref(), + Some("") + ); + assert_eq!( + decoded.artifact_links[&artifact_id].fragment.as_deref(), + Some("") + ); +} + +#[test] +fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() { + let mut project = read(OPERATIONS); + let allocation = identity(&project, "operation_probe_30"); + assert_eq!( + project.relations[&allocation].kind(), + RelationKind::InterfaceItemAllocation + ); + let selected = identity(&project, "operation_probe_43"); + let unresolved = identity(&project, "operation_probe_44"); + for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] { + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = project.elements.get_mut(&subject).unwrap().shared_mut() + else { + panic!("fixture must contain a Call Exchange"); + }; + assert_eq!(*operation, None); + *operation = Some(OperationUse { allocation: value }); + } + let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project); + assert!( + matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation) + ); + assert!(matches!( + decoded.elements[&unresolved].shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: Some(OperationUse { allocation: None }), + .. + }, + .. + }) + )); +} diff --git a/crates/oa-core/tests/default_omission_output.rs b/crates/oa-core/tests/default_omission_output.rs new file mode 100644 index 00000000..ce5e464b --- /dev/null +++ b/crates/oa-core/tests/default_omission_output.rs @@ -0,0 +1,256 @@ +//! Focused public-output contracts. Field locations come from the real compiler; +//! token comparisons ignore layout while retaining typed tags and required syntax. +use oa_core::{language, source_project::*, *}; +use std::collections::BTreeMap; + +fn project(declarations: &str) -> Project { + language::parse(&format!( + "arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}" + )).unwrap() +} + +fn exports(project: &Project) -> Vec { + 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 { + 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::>() + .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") + ); + } +} diff --git a/crates/oa-core/tests/default_omission_preservation.rs b/crates/oa-core/tests/default_omission_preservation.rs new file mode 100644 index 00000000..bd148c79 --- /dev/null +++ b/crates/oa-core/tests/default_omission_preservation.rs @@ -0,0 +1,612 @@ +//! Exact-source regressions for fields skipped by persistence serialization. +//! +//! The archived Windows baseline confirmed three exact-source failures after +//! full model equality passed. Complete comparison now preserves these fields; +//! the former ignored witnesses are normal regression tests. +//! No-op and same-record header controls remain normal tests: they must preserve +//! the same fixtures without entering the changed-payload record-erasure branch. + +use oa_core::{source_project::*, *}; +use std::collections::BTreeMap; + +const EXCHANGE_SOURCE: &str = r#"arcadia 7; +project p "Preservation" id "00000000-0000-4000-8000-000000000001" { + oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {} + oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {} + interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" { + source @first; target @second; + } + oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" { + steps [@subject]; + } + oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" { + parent @owner; + body { Exchange { + exchange none; + sender none; receiver none; + message_kind Asynchronous; reply_to none; values []; + operation none ; // Keep this authored default and its spelling. + }; }; + } +} +"#; + +const NODE_SOURCE: &str = r#"arcadia 7; +project p "Preservation" id "00000000-0000-4000-8000-000000000001" { + oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {} + diagram view "View" id "00000000-0000-4000-8000-000000000003" {} + node placement id "00000000-0000-4000-8000-000000000004" { + diagram @view; element @work; + position { x 12.5; y -4.0; }; + port_anchors [] ; // Keep explicit automatic placement. + } +} +"#; + +const TRACE_SOURCE: &str = r#"arcadia 7; +project p "Preservation" id "00000000-0000-4000-8000-000000000001" { + oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {} + sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {} + trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" { + oa @work; sa @function; kind Manual; + baseline { + oa { name "Work"; description "Old OA snapshot"; }; + sa { name "Function"; description "Old SA snapshot"; }; + semantic none ; // Keep the explicit prose-only baseline. + }; + } +} +"#; + +const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7; +project p "Preservation" id "00000000-0000-4000-8000-000000000001" { + oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" { + steps [@subject]; + } + oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { + parent @owner; + body { Action { + action none; + lifeline none ; // Keep this shared field's distinctive spelling. + }; }; + } +} +"#; + +const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7; +project p "Preservation" id "00000000-0000-4000-8000-000000000001" { + oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {} + oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {} + // These port declarations are untouched by the relation context change. + oa_port output "Output" id "00000000-0000-4000-8000-000000000005" { + parent @first; contract Legacy; + legacy { direction Out; interface none; }; + } + oa_port input "Input" id "00000000-0000-4000-8000-000000000006" { + parent @second; contract Legacy; + legacy { direction In; interface none; }; + } + boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" { + source @output; target @input; + oriented true ; // Retain this rationale when changing the default value. + items []; + } +} +"#; + +fn id(number: u8) -> Id { + Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap() +} + +fn source_bundle(source: &str) -> SourceBundle { + SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), source.into())]), + } +} + +fn project() -> Project { + Project { + schema_version: 7, + id: id(1), + alias: "p".into(), + name: "Preservation".into(), + description: String::new(), + elements: BTreeMap::new(), + relations: BTreeMap::new(), + diagrams: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + } +} + +fn element(number: u8, alias: &str, name: &str, parent: Option, 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]); +} diff --git a/crates/oa-core/tests/default_omission_updates.rs b/crates/oa-core/tests/default_omission_updates.rs new file mode 100644 index 00000000..c718d21e --- /dev/null +++ b/crates/oa-core/tests/default_omission_updates.rs @@ -0,0 +1,222 @@ +//! Public update witnesses for compact insertion and complete comparison. +use oa_core::{language, source_project::*, *}; +use std::collections::BTreeMap; + +fn compile(body: &str) -> CompiledSourceProject { + compile_bundle( + &SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ( + "project.arcadia".into(), + "arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(), + ), + ("model.arcadia".into(), body.into()), + ]), + }, + ProjectLimits::default(), + ) + .unwrap() +} +fn id(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} +fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject { + let result = update_bundle(before, project, ProjectLimits::default()).unwrap(); + assert_eq!(result.decoded.project, *project); + assert_eq!( + result.bundle.files["project.arcadia"], + before.bundle.files["project.arcadia"] + ); + assert_eq!( + compile_bundle(&result.bundle, ProjectLimits::default()) + .unwrap() + .bundle, + result.bundle + ); + assert_eq!( + update_bundle(&result, project, ProjectLimits::default()) + .unwrap() + .bundle, + result.bundle + ); + result +} +fn words(text: &str) -> Vec<&str> { + language::tokenize(text) + .into_iter() + .filter(|t| { + !matches!( + t.class, + language::TokenClass::Whitespace | language::TokenClass::Comment + ) + }) + .map(|t| &text[t.span.start..t.span.end]) + .collect() +} + +#[test] +fn omitted_exchange_body_changes_to_one_compact_action_constructor() { + let before = compile( + "oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n", + ); + let mut project = before.decoded.project.clone(); + let occurrence = id(&project, "o"); + let Some(SharedBody::Occurrence { body, .. }) = + project.elements.get_mut(&occurrence).unwrap().shared_mut() + else { + panic!("occurrence") + }; + assert!(matches!(body, OccurrenceBody::Exchange { .. })); + *body = OccurrenceBody::Action { + action: None, + lifeline: None, + }; + let result = update(&before, &project); + let text = &result.bundle.files["model.arcadia"]; + let tokens = words(text); + assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1); + assert!(tokens.contains(&"Action")); + for absent in ["Exchange", "action", "lifeline", "operation", "description"] { + assert!(!tokens.contains(&absent), "{absent}: {text}"); + } + assert!(text.contains("parent @s; // keep this")); +} + +#[test] +fn default_signal_constructor_still_inserts_its_required_empty_items() { + let before = + compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n"); + let mut project = before.decoded.project.clone(); + let event = id(&project, "e"); + let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut() + else { + panic!("event") + }; + *body = EventBody::Signal { items: vec![] }; + let result = update(&before, &project); + let tokens = words(&result.bundle.files["model.arcadia"]); + assert!(tokens.contains(&"Signal")); + assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"])); + assert!(!tokens.contains(&"expression")); + assert!(!tokens.contains(&"kind")); +} + +#[test] +fn scalar_to_default_string_record_retains_required_minimum_only() { + let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n"); + let mut project = before.decoded.project.clone(); + let datatype = id(&project, "t"); + let Some(SharedBody::DataType { definition, .. }) = + project.elements.get_mut(&datatype).unwrap().shared_mut() + else { + panic!("data type") + }; + *definition = DataTypeDefinition::String { + minimum_length: 0, + maximum_length: None, + }; + let result = update(&before, &project); + let text = &result.bundle.files["model.arcadia"]; + let tokens = words(text); + assert!(tokens.contains(&"String")); + assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"])); + assert!(!tokens.contains(&"maximum_length")); + assert!(text.contains("// typed rationale")); +} + +#[test] +fn inserted_route_list_item_omits_only_optional_fields() { + let before = compile( + "oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n", + ); + let mut project = before.decoded.project.clone(); + let diagram = id(&project, "d"); + let edge = id(&project, "flow"); + // Deserialize only the explicit witness value, independently of omission policy. + project.diagrams.get_mut(&diagram).unwrap().routes = + serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}])) + .unwrap(); + let result = update(&before, &project); + let text = &result.bundle.files["model.arcadia"]; + let tokens = words(text); + assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"])); + assert!(!tokens.contains(&"waypoints")); + assert!(!tokens.contains(&"label")); + assert!(text.contains("// routing rationale")); +} + +#[test] +fn zero_sign_change_inserts_position_without_default_siblings() { + let before = compile( + "oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n", + ); + let mut project = before.decoded.project.clone(); + let diagram = id(&project, "d"); + let node = id(&project, "n"); + assert_eq!( + project.diagrams[&diagram].nodes[&node].position.x.to_bits(), + 0.0_f32.to_bits() + ); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .position + .x = -0.0; + let result = update(&before, &project); + assert_eq!( + result.decoded.project.diagrams[&diagram].nodes[&node] + .position + .x + .to_bits(), + (-0.0_f32).to_bits() + ); + let text = &result.bundle.files["model.arcadia"]; + let tokens = words(text); + assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"])); + for absent in ["size", "collapsed", "port_anchors"] { + assert!(!tokens.contains(&absent)); + } + assert!(text.contains("// placement rationale")); +} + +#[test] +fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() { + let before = compile( + "oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n", + ); + let mut project = before.decoded.project.clone(); + let flow = id(&project, "flow"); + assert!(matches!( + project.relations[&flow].body, + RelationBody::BoundaryExchange { oriented: true, .. } + )); + project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean { + source: id(&before.decoded.project, "a"), + target: id(&before.decoded.project, "b"), + oriented: true, + items: vec![], + }; + let result = update(&before, &project); + let text = &result.bundle.files["model.arcadia"]; + assert!( + words(text) + .windows(3) + .any(|w| w == ["oriented", "true", ";"]) + ); + assert!(text.contains("// orientation inherited")); + let before_ports = &before.bundle.files["model.arcadia"]; + assert_eq!( + text.split("communication_mean").next().unwrap(), + before_ports.split("boundary_exchange").next().unwrap() + ); +} diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs index b598ed8a..d18394f8 100644 --- a/crates/oa-core/tests/language.rs +++ b/crates/oa-core/tests/language.rs @@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *}; fn read(source: &str) -> Project { language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}")) } +fn replace_once(source: &str, before: &str, after: &str) -> String { + assert_eq!( + source.matches(before).count(), + 1, + "unique mutation anchor {before:?}" + ); + let changed = source.replacen(before, after, 1); + assert_ne!(changed, source, "test mutation must change the source"); + changed +} fn rejected(source: &str, fragment: &str) { let errors = language::parse(source).expect_err(source); assert!( @@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { source.push('}'); let model = read(&source); let canonical = language::format(&model); + assert!(!canonical.contains("transition_kind External;")); for fragment in [ - "transition_kind External;", "transition_kind Local;", "transition_kind Internal;", "triggers [@deadline, @assess];", @@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { .find(|r| r.alias == "recover") .unwrap(); let RelationBody::StateTransition { + transition_kind, trigger, guard, effect, @@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { else { panic!("expected transition") }; + assert_eq!(*transition_kind, TransitionKind::External); assert_eq!(trigger, "Operator confirms\nНе запускать код"); assert_eq!(guard, "available && status == \"Ready\""); assert_eq!(effect, "Record the assessment"); + // External is omitted in compact output. Insert an explicit invalid + // discriminator into Recover using its unique, nondefault trigger list. + let invalid_kind = replace_once( + &canonical, + "triggers [@deadline, @assess];", + "transition_kind Unsupported;\n triggers [@deadline, @assess];", + ); + rejected(&invalid_kind, "Unsupported"); for invalid in [ - canonical.replace("transition_kind External;", "transition_kind Unsupported;"), - canonical.replace( + replace_once( + &canonical, "triggers [@deadline, @assess];", "triggers [\"deadline\", @assess];", ), - canonical.replace( + replace_once( + &canonical, "triggers [@deadline, @assess];", "triggers [@deadline, @deadline];", ), - canonical.replace("effects [@assess];", "effects [@payload];"), - canonical.replace("kind After;", "kind Relative;"), - canonical.replace("items [@payload];", "items [@assess];"), + replace_once(&canonical, "effects [@assess];", "effects [@payload];"), + replace_once(&canonical, "kind After;", "kind Relative;"), + replace_once(&canonical, "items [@payload];", "items [@assess];"), ] { assert!( language::parse(&invalid).is_err(), "invalid typed state/event source accepted" ); } - let legacy = canonical - .replace("transition_kind External;", "") - .replace("triggers [@deadline, @assess];", "") - .replace("effects [@assess];", ""); + // Canonical source already omits External. Removing the supplied lists + // independently exercises their empty omission defaults and retained prose. + let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", ""); + let legacy = replace_once(&legacy, "effects [@assess];", ""); let legacy = read(&legacy); let RelationBody::StateTransition { transition_kind, @@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { (retained_trigger, retained_guard, retained_effect), (trigger, guard, effect) ); - let draft = canonical.replace("expression \"30 s\";", "expression \"\";"); + let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";"); assert!(language::parse(&draft).is_ok()); let deadline = model .elements @@ -1038,7 +1059,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi "fixture must cover actual old scenario content" ); let canonical = language::format(&from_source); - assert!(canonical.contains("viewpoint MixedLegacy;")); + assert!(!canonical.contains("viewpoint MixedLegacy;")); for relation in from_source.relations.values() { if let RelationBody::StateTransition { transition_kind, diff --git a/crates/oa-core/tests/port_anchors.rs b/crates/oa-core/tests/port_anchors.rs index bb92f098..b0c70657 100644 --- a/crates/oa-core/tests/port_anchors.rs +++ b/crates/oa-core/tests/port_anchors.rs @@ -767,7 +767,15 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec assert_eq!(cleared.decoded.project, prepared.project); let text = &cleared.bundle.files["project.arcadia"]; assert!(text.contains("// first rationale") && text.contains("// second rationale")); - assert!(!text.contains("port_anchors")); + // Lossless editing may retain the authored empty field and its annotations. + // Canonical export, rather than a model update, normalizes default syntax. + assert!(!language::format(&cleared.decoded.project).contains("port_anchors")); + assert_eq!( + update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default()) + .unwrap() + .bundle, + cleared.bundle + ); let prepared = prepare_command( &cleared.decoded.project, &Command::SetPortAnchor { diff --git a/crates/oa-core/tests/source_files.rs b/crates/oa-core/tests/source_files.rs index 8749684a..0c62458b 100644 --- a/crates/oa-core/tests/source_files.rs +++ b/crates/oa-core/tests/source_files.rs @@ -383,3 +383,87 @@ fn atomic_save_preserves_existing_unix_permissions() { 0o640 ); } + +#[test] +fn discovery_diagnostics_keep_raw_no_uuid_spans() { + let directory = setup(); + let path = directory.path().join("parts/a.arcadia"); + let raw = + "// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n"; + fs::write(&path, raw).unwrap(); + let disk = load_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(disk.bundle.files["parts/a.arcadia"], raw); + let issue = disk + .diagnostics + .iter() + .find(|issue| { + issue.diagnostic.code == "unallocated_leaf" + && issue.diagnostic.message.contains("Second") + }) + .unwrap(); + assert_eq!(issue.path, "parts/a.arcadia"); + assert_eq!(issue.diagnostic.line, 3); + assert_eq!(issue.diagnostic.column, 1); + let span = issue.diagnostic.span; + assert_eq!(span.start, raw.find("sa_function second").unwrap()); + assert_eq!( + &raw[span.start..span.end], + "sa_function second \"Second\" {}" + ); + let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap(); + assert_ne!(compiled.bundle, disk.bundle); + assert!(compiled.diagnostics.iter().any(|later| { + later.diagnostic.code == issue.diagnostic.code + && later.diagnostic.message == issue.diagnostic.message + && later.diagnostic.span.start > span.start + })); + assert_eq!(fs::read_to_string(path).unwrap(), raw); +} + +#[test] +fn discovery_diagnoses_legacy_semantics_before_source_rewrite() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("project.arcadia"); + let raw = include_str!("fixtures/v3-data-contract.arcadia"); + fs::write(&path, raw).unwrap(); + let disk = load_project(&path, ProjectLimits::default()).unwrap(); + assert_eq!(disk.bundle.files["project.arcadia"], raw); + assert!(!disk.diagnostics.is_empty()); + assert!( + disk.diagnostics + .iter() + .all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning }) + ); + assert_eq!( + disk.diagnostics, + lint_bundle(&disk.reachable_bundle(), ProjectLimits::default()) + ); + let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap(); + assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3); + assert_ne!(compiled.bundle, disk.bundle); + for issue in &disk.diagnostics { + assert!(compiled.diagnostics.iter().any(|later| { + later.diagnostic.code == issue.diagnostic.code + && later.diagnostic.object_id == issue.diagnostic.object_id + && later.diagnostic.message == issue.diagnostic.message + })); + let span = issue.diagnostic.span; + assert!(raw.get(span.start..span.end).is_some()); + } + // Invalid legacy references still fail during semantic resolution on the + // authored bytes; deferring the source rewrite does not hide those errors. + let invalid = raw.replace("@coordinate", "@missing"); + fs::write(&path, &invalid).unwrap(); + let broken = load_project(&path, ProjectLimits::default()).unwrap(); + assert!(broken.diagnostics.iter().any(|issue| { + issue.diagnostic.severity == oa_core::validation::Severity::Error + && issue.diagnostic.message.contains("@missing") + })); + assert_eq!(broken.bundle.files["project.arcadia"], invalid); + assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), invalid); +} diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index e12ccd37..1e80ec40 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -20,6 +20,7 @@ mod reference_authoring; mod review_navigation_tests; pub(crate) mod source_rename; mod source_symbols; +pub(crate) mod text_workspace; #[cfg(test)] mod transition_context_tests; #[cfg(test)] @@ -440,8 +441,7 @@ pub enum Message { IncludesLoaded(u64, Result), Lint(u64), Linted(u64, Result, String>), - SourceDiagnostic(usize), - SourceRelated(SourceLocation), + TextWorkspace(text_workspace::Event), SourceViewport(Option), SourceSymbols( crate::source_navigation::SymbolContext, @@ -537,6 +537,7 @@ pub struct App { pub source_navigation: crate::source_navigation::State, source_find_instance: Id, pub source_symbols: crate::source_navigation::SymbolState, + pub text_workspace: text_workspace::State, pub source_rename: source_rename::State, pub(crate) diagram_usages: Option, pub(crate) diagram_navigation_focus: Option, @@ -659,6 +660,7 @@ impl App { source_navigation: crate::source_navigation::State::default(), source_find_instance: Id::new_v4(), source_symbols: crate::source_navigation::SymbolState::default(), + text_workspace: text_workspace::State::default(), source_rename: source_rename::State::default(), diagram_usages: None, diagram_navigation_focus: None, @@ -713,10 +715,35 @@ impl App { fn sync_source(&mut self) { self.sync_source_with_alias_editor(None); } + fn publish_source_diagnostics(&mut self, diagnostics: Vec) { + 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>) { self.source_workspace.accept(self.document.bundle()); self.show_source_file_with_alias_editor(false, hint); - self.source_diagnostics = self.document.compiled().diagnostics.clone(); + self.publish_source_diagnostics(self.document.compiled().diagnostics.clone()); self.source_version += 1; self.lint_requested = None; } @@ -857,7 +884,7 @@ impl App { } } Err(issues) => { - self.source_diagnostics = issues; + self.publish_source_diagnostics(issues); self.status = "Source contains errors. Your draft is preserved.".into(); self.page = Page::Text; false @@ -1013,6 +1040,7 @@ impl App { let navigation_in_flight = self.source_symbols.in_flight; let rename_in_flight = self.source_rename.in_flight; let diagram_lookup_in_flight = self.diagram_lookup_in_flight; + let menu_sequence = self.menu_sequence; let guide = std::mem::take(&mut self.guide); let mut hosted = std::mem::take(&mut self.hosted); hosted.leave_document(); @@ -1027,6 +1055,7 @@ impl App { self.source_symbols.in_flight = navigation_in_flight; self.source_rename.in_flight = rename_in_flight; self.diagram_lookup_in_flight = diagram_lookup_in_flight; + self.menu_sequence = menu_sequence; self.guide = guide; self.path = path; self.status = "Project ready".into(); @@ -1124,7 +1153,7 @@ impl App { pub fn view(&self) -> Element<'_, Message> { let base = crate::context_menu::Popup::new( crate::views::view(self), - crate::model_menu::popup(self), + crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()), ) .into(); crate::source_navigation::interaction::observe( @@ -1192,14 +1221,15 @@ impl App { // Use one captured-Escape bridge: a focused Find input consumes // Escape, while modal priority remains in the ordinary handler. behavior::creation::subscription( - self.behavior.creation.has_pending() - || self.diagram_usages.is_some() - || self.behavior.source_active() - || (self.page == Page::Text - && (self.source_symbols.open - || self.source_navigation.open - || self.source_rename.open()) - && self.architecture.is_none()), + self.text_workspace.menu().is_none() + && (self.behavior.creation.has_pending() + || self.diagram_usages.is_some() + || self.behavior.source_active() + || (self.page == Page::Text + && (self.text_workspace.drawer().is_some() + || self.source_navigation.open + || self.source_rename.open()) + && self.architecture.is_none())), ) .map(|()| Message::Escape), iced::event::listen_with(|event, status, _| { @@ -1249,6 +1279,11 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // Captured Text commands validate their origin before they can + // invalidate that same origin or pass into existing command handlers. + if let Message::TextWorkspace(event) = message { + return self.text_workspace_event(event); + } // These handlers reject old owners/tokens without changing state. Apply // the same admission before shared camera and allocation boundaries. if !self.source_input_envelope(&message) @@ -1411,6 +1446,7 @@ impl App { } message => message, }; + // Internal results and geometry do not constitute a new user intent. // Process query completion even behind a modal to release its worker slot; // its handler independently refuses navigation outside the live editor. @@ -1423,6 +1459,38 @@ impl App { if let Message::DiagramUsagesReady(completion) = message { return self.diagram_usages_ready(completion); } + if self.text_workspace.menu().is_some() { + if matches!( + message, + Message::Escape | Message::Request(Pending::Close(_)) + ) { + self.text_workspace.dismiss_menu(); + self.source_interaction(); + return Task::none(); + } + // Global subscriptions can receive captured keyboard input. + // A Text menu owns those commands while passive work can finish. + if !matches!( + message, + Message::CheckpointWritten(..) + | Message::CheckpointLoaded(..) + | Message::CheckpointTick + | Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::SourceViewport(_) + | Message::Lint(_) + | Message::Linted(..) + | Message::Saved(_) + | Message::Hosted(crate::hosted::Event::Completed(..)) + | Message::Loaded(_) + | Message::IncludesLoaded(..) + | Message::SavePath(_) + | Message::SourceRecovered(_) + | Message::Exported(_) + ) { + return Task::none(); + } + } if !matches!( &message, Message::CheckpointTick @@ -2190,11 +2258,11 @@ impl App { // Validate the originating canvas before advancing its input // epoch. Actions are then generated with the refreshed context, // while pointer history hidden behind the menu becomes stale. - let Some(opened) = crate::model_menu::Opened::new( - self, - request, - self.menu_sequence.wrapping_add(1), - ) else { + let Some(token) = self.menu_sequence.checked_add(1) else { + self.status = "Reopen the application to start a new menu session.".into(); + return Task::none(); + }; + let Some(opened) = crate::model_menu::Opened::new(self, request, token) else { return Task::none(); }; self.connection = None; @@ -2248,7 +2316,8 @@ impl App { } Message::Escape => { let restore_source_focus = self.source_navigation_available() - && (self.source_navigation.open || self.source_symbols.open); + && (self.source_navigation.open || self.text_workspace.drawer().is_some()); + let dismiss_find_only = restore_source_focus && self.source_navigation.open; if self.behavior.source_back() { self.status = "Returned to the unchanged parent form; no source was created.".into(); @@ -2264,15 +2333,11 @@ impl App { if self.connection.take().is_some() { self.status = "Drawing cancelled · Select / move is active".into(); } - if restore_source_focus && self.source_navigation.open { - // Dismiss the active Find surface first. Its underlying - // references remain available until a separate Escape. - self.source_navigation.open = false; - } else { - self.source_navigation.open = false; - self.source_symbols.open = false; - self.source_symbols.results = None; - self.source_symbols.notice = None; + self.source_navigation.open = false; + // Popup/modal priority was handled above. In the available Text + // workspace, one Escape dismisses Find before the drawer beneath it. + if !dismiss_find_only { + self.text_workspace.show_drawer(None); } if restore_source_focus { self.restore_source_editor_focus(); @@ -3260,7 +3325,7 @@ impl App { .edit(after.clone(), self.source_editor.cursor()); self.source_snapshot = Arc::from(after); self.source_version += 1; - self.source_diagnostics.clear(); + self.publish_source_diagnostics(Vec::new()); let version = self.source_version; return Task::perform( async move { @@ -3325,8 +3390,17 @@ impl App { } self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again."); self.pending_recovery = None; + // Removing the recovered-save notice changes presentation, + // not the source authority of the remaining locations. + let diagnostics_current = self + .text_workspace + .diagnostic_publication() + .is_some_and(|publication| publication.source() == self.source_key()); self.source_diagnostics .retain(|issue| issue.diagnostic.code != "pending_save"); + if diagnostics_current { + self.publish_source_diagnostics(self.source_diagnostics.clone()); + } match receipt.disk { Ok(disk) if !self.source_workspace.applied => { self.open_source_project(disk) @@ -3338,7 +3412,7 @@ impl App { || self.path.as_ref() == Some(&receipt.target) { if self.path.as_ref() != Some(&receipt.target) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.path = Some(receipt.target); self.document.bind_repair_baseline(disk.reachable_bundle()); @@ -3366,13 +3440,24 @@ impl App { if version == self.source_version { match result { Ok(disk) => { + // Discovery must include the exact overlays retained + // by this workspace. A current request version alone + // does not prove arbitrary reply spans match them. + if disk.bundle.entry != self.source_workspace.entry + || self.source_workspace.files.iter().any(|(path, buffer)| { + disk.bundle.files.get(path) != Some(&buffer.text) + }) + { + self.status = "Included-file results no longer match the retained drafts. Load includes again.".into(); + return self.request_lint(); + } if let Some(target) = recovery_target(&disk) { self.pending_recovery = Some(target); } self.source_workspace .discovered(&disk.bundle, disk.reachable.clone()); self.source_version += 1; - self.source_diagnostics = disk.diagnostics.clone(); + self.publish_source_diagnostics(disk.diagnostics.clone()); self.disk_project = Some(disk); self.status = "Included files loaded; existing drafts retained.".into(); return self.request_lint(); @@ -3393,21 +3478,13 @@ impl App { self.lint_running = None; if version == self.source_version { match result { - Ok(diagnostics) => self.source_diagnostics = diagnostics, + Ok(diagnostics) => self.publish_source_diagnostics(diagnostics), Err(error) => self.status = format!("Source checks failed: {error}"), } } return self.start_pending_lint(); } - Message::SourceDiagnostic(index) => { - if let Some(issue) = self.source_diagnostics.get(index) { - self.jump_to_source(SourceLocation { - path: issue.path.clone(), - span: issue.diagnostic.span, - }); - } - } - Message::SourceRelated(location) => self.jump_to_source(location), + Message::TextWorkspace(_) => return Task::none(), // checked before generic input Message::SourceViewport(ticket) => return self.source_viewport(ticket), Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it Message::SourceSymbols(_, event) => return self.source_symbol_event(event), @@ -3449,7 +3526,7 @@ impl App { if self.source_workspace.undo(redo) { self.show_source_file(true); self.source_version += 1; - self.source_diagnostics.clear(); + self.publish_source_diagnostics(Vec::new()); return self.request_lint(); } } @@ -3597,7 +3674,7 @@ impl App { self.sync_source(); } if self.path.as_ref() != Some(&receipt.path) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.disk_project = Some(disk); self.path = Some(receipt.path); @@ -4139,7 +4216,7 @@ impl App { self.source_workspace .discovered(&disk.bundle, disk.reachable.clone()); self.show_source_file(false); - self.source_diagnostics = self.document.compiled().diagnostics.clone(); + self.publish_source_diagnostics(self.document.compiled().diagnostics.clone()); self.migration = self.document.compiled().decoded.migration.clone(); if self.migration.is_some() { self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into(); @@ -4159,9 +4236,10 @@ impl App { self.path = Some(path); self.document.bind_repair_baseline(disk.reachable_bundle()); self.source_workspace = Workspace::new(&disk.bundle, false); + self.clear_source_presentation(); self.source_workspace.set_reachable(disk.reachable.clone()); self.show_source_file(false); - self.source_diagnostics = error.diagnostics().to_vec(); + self.publish_source_diagnostics(error.diagnostics().to_vec()); self.source_version += 1; self.lint_requested = None; self.page = Page::Text; @@ -5226,7 +5304,16 @@ mod source_project_tests { assert_eq!(app.project(), &project); assert!(!app.apply_source()); assert_eq!(app.source_diagnostics[0].path, fragment); - send(&mut app, Message::SourceDiagnostic(0)); + send(&mut app, Message::Page(Page::Text)); + let _ = app.show_text_drawer(Some(text_workspace::DrawerTab::Problems)); + let issue = &app.source_diagnostics[0]; + let message = app + .text_diagnostic_message(SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, + }) + .unwrap(); + send(&mut app, message); assert_eq!(app.source_workspace.active, fragment); assert_eq!(app.source_editor.text(), "? invalid source"); send(&mut app, Message::RevertSource); @@ -5268,6 +5355,275 @@ mod source_project_tests { ); assert!(!app.dirty()); } + fn open_missing_function_file(directory: &Directory) -> App { + let entry = format!( + "arcadia {}; project p \"P\" {{ include \"work.arcadia\"; }}\n", + oa_core::SCHEMA_VERSION + ); + let path = directory.0.join("project.arcadia"); + std::fs::write(&path, entry).unwrap(); + let mut app = App::with_project(Project::demo()); + send( + &mut app, + Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))), + ); + assert!(!app.source_workspace.applied); + assert!(!app.source_workspace.files.contains_key("work.arcadia")); + app + } + + fn assert_raw_second_warning(app: &App, raw: &str) { + let issue = app + .source_diagnostics + .iter() + .find(|issue| { + issue.path == "work.arcadia" + && issue.diagnostic.code == "unallocated_leaf" + && issue.diagnostic.message.contains("Second") + }) + .unwrap(); + let span = issue.diagnostic.span; + assert_eq!(span.start, raw.find("sa_function second").unwrap()); + assert_eq!( + &raw[span.start..span.end], + "sa_function second \"Second\" {}" + ); + assert_eq!( + app.text_workspace + .diagnostic_publication() + .unwrap() + .source(), + app.source_key() + ); + } + + #[test] + fn includes_loaded_diagnostics_use_raw_newly_discovered_bytes() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + let model = app.document.snapshot(); + let publication = app.text_workspace.diagnostic_publication(); + let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n"; + std::fs::write(directory.0.join("work.arcadia"), raw).unwrap(); + let disk = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + // Real producer control: compiled diagnostics would refer to inserted + // IDs, while IncludesLoaded must leave the raw draft unapplied. + let compiled = source_project::compile_bundle(&disk.reachable_bundle(), LIMITS).unwrap(); + assert_ne!(compiled.bundle, disk.bundle); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(disk))); + assert_raw_second_warning(&app, raw); + assert_eq!(app.source_workspace.files["work.arcadia"].text, raw); + assert!(!app.source_workspace.applied); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + assert_ne!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!( + std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(), + raw + ); + } + + #[test] + fn includes_loaded_diagnostics_use_retained_overlay_and_ignore_old_version() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n"; + std::fs::write(directory.0.join("work.arcadia"), raw).unwrap(); + let first = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(first))); + let model = app.document.snapshot(); + let previous = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let previous_version = app.source_version; + send(&mut app, Message::SourceFile("work.arcadia".into())); + let edited = format!("// Retained Unicode draft: 航海\n{raw}"); + edit(&mut app, &edited); + let current = source_workspace::lint(&app.source_workspace.bundle()); + app.publish_source_diagnostics(current); + let publication = app.text_workspace.diagnostic_publication(); + let key = app.source_key(); + send( + &mut app, + Message::IncludesLoaded(previous_version, Ok(previous)), + ); + assert_eq!(app.source_key(), key); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_raw_second_warning(&app, &edited); + + std::fs::write(directory.0.join("work.arcadia"), "// external disk edit\n").unwrap(); + let discovered = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + assert_eq!(discovered.bundle.files["work.arcadia"], edited); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(discovered))); + assert_raw_second_warning(&app, &edited); + assert_ne!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_editor.text(), edited); + assert_eq!(app.source_workspace.files["work.arcadia"].text, edited); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + assert_eq!( + std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(), + "// external disk edit\n" + ); + } + + #[test] + fn includes_loaded_rejects_mismatched_retained_bytes_before_publication() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + std::fs::write( + directory.0.join("work.arcadia"), + "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n", + ) + .unwrap(); + // Discovery can also report a pending recovery target. A rejected + // reply must not install that metadata either. + std::fs::create_dir(directory.0.join(".arcadia-save-project.arcadia.journal")).unwrap(); + let reply = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + assert!(recovery_target(&reply).is_some()); + let edited = format!("// retained entry draft\n{}", app.source_editor.text()); + edit(&mut app, &edited); + let current = source_workspace::lint(&app.source_workspace.bundle()); + app.publish_source_diagnostics(current); + assert!(!app.source_diagnostics.is_empty()); + let publication = app.text_workspace.diagnostic_publication(); + let diagnostics = app.source_diagnostics.clone(); + let before = app.source_workspace.bundle(); + let key = app.source_key(); + let reachable = app.source_workspace.reachable.clone(); + let disk = app.disk_project.clone().unwrap(); + let model = app.document.snapshot(); + let recovery = app.pending_recovery.clone(); + // Deliberately stamp an older real producer reply with the current + // version: this tests byte admission independently of version refusal. + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(reply))); + assert_eq!(app.source_version, version); + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.bundle(), before); + assert_eq!(app.source_workspace.reachable, reachable); + assert!(!app.source_workspace.files.contains_key("work.arcadia")); + assert_eq!(app.source_editor.text(), edited); + assert_eq!(app.pending_recovery, recovery); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_diagnostics, diagnostics); + let after = app.disk_project.as_ref().unwrap(); + assert_eq!(after.root, disk.root); + assert_eq!(after.bundle, disk.bundle); + assert_eq!(after.expectations, disk.expectations); + assert_eq!(after.reachable, disk.reachable); + assert_eq!(after.diagnostics, disk.diagnostics); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + } + + #[test] + fn includes_loaded_requires_each_inactive_detached_overlay() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + let original = "sa_function first \"First\" {}\n"; + std::fs::write(directory.0.join("work.arcadia"), original).unwrap(); + let discovered = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(discovered))); + send(&mut app, Message::SourceFile("work.arcadia".into())); + let retained = format!("// retained detached draft\n{original}"); + edit(&mut app, &retained); + send(&mut app, Message::SourceFile("project.arcadia".into())); + let entry = format!( + "arcadia {}; project p \"P\" {{}}\n", + oa_core::SCHEMA_VERSION + ); + edit(&mut app, &entry); + assert!(app.apply_source(), "{}", app.status); + assert_eq!(app.source_workspace.active, app.source_workspace.entry); + assert!(!app.source_workspace.reachable.contains("work.arcadia")); + assert_eq!(app.source_workspace.files["work.arcadia"].text, retained); + let valid = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let before = app.source_workspace.bundle(); + let key = app.source_key(); + let publication = app.text_workspace.diagnostic_publication(); + let diagnostics = app.source_diagnostics.clone(); + let disk = app.disk_project.clone().unwrap(); + let model = app.document.snapshot(); + for missing in [false, true] { + let mut reply = valid.clone(); + if missing { + reply.bundle.files.remove("work.arcadia"); + } else { + reply + .bundle + .files + .insert("work.arcadia".into(), original.into()); + } + // Entry and active text match exactly. Only an inactive, detached + // overlay is wrong or absent; checking active/entry alone would pass. + assert_eq!( + reply.bundle.files[&app.source_workspace.entry], + app.source_editor.text() + ); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(reply))); + assert_eq!(app.source_version, version); + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.bundle(), before); + assert!(!app.source_workspace.reachable.contains("work.arcadia")); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_diagnostics, diagnostics); + let after = app.disk_project.as_ref().unwrap(); + assert_eq!(after.bundle, disk.bundle); + assert_eq!(after.expectations, disk.expectations); + assert_eq!(after.reachable, disk.reachable); + assert_eq!(after.diagnostics, disk.diagnostics); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + } + // Exact overlays remain admissible even when the retained draft is + // detached and its external file still contains the original bytes. + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(valid))); + assert_eq!(app.source_version, version + 1); + assert_ne!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_workspace.bundle(), before); + assert_eq!(app.source_workspace.files["work.arcadia"].text, retained); + assert_eq!( + std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(), + original + ); + } + #[test] fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() { let directory = Directory::new(); diff --git a/crates/oa-desktop/src/app/hosted.rs b/crates/oa-desktop/src/app/hosted.rs index 7cea3328..41fff3f6 100644 --- a/crates/oa-desktop/src/app/hosted.rs +++ b/crates/oa-desktop/src/app/hosted.rs @@ -219,7 +219,7 @@ impl App { || session.connection.base() != request.connection.base() }) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.hosted.session = Some(Session { connection: request.connection.clone(), @@ -359,7 +359,7 @@ impl App { || session.connection.base() != connection.base() }) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.hosted.session = Some(Session { connection, diff --git a/crates/oa-desktop/src/app/source_rename.rs b/crates/oa-desktop/src/app/source_rename.rs index 131976a9..00e3d620 100644 --- a/crates/oa-desktop/src/app/source_rename.rs +++ b/crates/oa-desktop/src/app/source_rename.rs @@ -215,7 +215,7 @@ impl App { } } - fn rename_admission(&self) -> Result<(), String> { + pub(super) fn rename_admission(&self) -> Result<(), String> { if self.page != Page::Text || self.busy || self.pending.is_some() diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs index 94f5426a..053b0cc1 100644 --- a/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs @@ -359,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job)); send(&mut app, Message::SourceSymbolsReady(source_ready)); assert!(app.source_symbols.in_flight.is_none()); - assert!(!app.source_symbols.open); + assert!(app.text_workspace.drawer().is_none()); send(&mut app, Message::DiagramUsagesReady(old_diagram)); assert_eq!(app.diagram_lookup_in_flight, Some(new_token)); assert!(app.diagram_usages.as_ref().unwrap().loading); diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs index 7362e330..959e48ba 100644 --- a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs @@ -164,3 +164,125 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() { assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); first_measurement(&mut app, &found); } + +#[cfg(debug_assertions)] +#[test] +fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() { + use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; + for changed in 0..3 { + let mut app = navigation_fixture(); + app.text_workspace.show_drawer(Some(DrawerTab::Status)); + let origin = app.text_drawer_context(); + let captured = [ + Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)), + Message::TextWorkspace(TextEvent::HideDrawer(origin)), + crate::views::captured_text_drawer_resize(&app), + ]; + match changed { + 0 => app.canvas_document = Id::new_v4(), + 1 => app.source_workspace.reset_navigation_session(), + _ => app.text_workspace.show_drawer(Some(DrawerTab::Status)), + } + let found = navigate(&mut app); + // Keep command availability true to isolate the stale owner check from + // the page guard, while a newer measured navigation is still pending. + app.page = Page::Text; + assert!(app.source_navigation_available()); + let destination = navigation(&app); + let allocation = app.allocation_boundary(); + let camera = app.camera_boundary(); + let allocation_epoch = app.allocation_drag.epoch; + let interaction = app.source_symbols.interaction; + let drawer = app.text_workspace.drawer(); + let height = app.text_workspace.drawer_height(); + let visit = app.text_workspace.drawer_visit(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let status = app.status.clone(); + for message in captured { + send(&mut app, message); + assert_eq!(navigation(&app), destination); + assert!(app.allocation_boundary() == allocation); + assert!(app.camera_boundary() == camera); + assert_eq!(app.allocation_drag.epoch, allocation_epoch); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.text_workspace.drawer(), drawer); + assert_eq!(app.text_workspace.drawer_height(), height); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert_eq!(app.status, status); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + app.page = Page::Diagram; + first_measurement(&mut app, &found); + } +} + +#[cfg(debug_assertions)] +#[test] +fn current_compact_drawer_controls_and_mounted_resize_remain_live() { + use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; + let mut app = navigation_fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + navigate(&mut app); + app.page = Page::Text; + assert!(app.pending_diagram_reveal.is_some()); + let epoch = app.allocation_drag.epoch; + let show = Message::TextWorkspace(TextEvent::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Status, + )); + send(&mut app, show); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status)); + assert!(app.pending_diagram_reveal.is_none()); + assert!(app.diagram_navigation_focus.is_none()); + assert!(app.allocation_drag.epoch > epoch); + let epoch = app.allocation_drag.epoch; + let height = app.text_workspace.drawer_height(); + let resize = crate::views::captured_text_drawer_resize(&app); + send(&mut app, resize); + assert_eq!(app.text_workspace.drawer_height(), height + 60.); + assert!(app.allocation_drag.epoch > epoch); + let epoch = app.allocation_drag.epoch; + let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())); + send(&mut app, hide); + assert!(app.text_workspace.drawer().is_none()); + assert!(app.allocation_drag.epoch > epoch); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn compact_diagram_usages_command_uses_captured_query_and_existing_chooser() { + let mut app = navigation_fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages); + send(&mut app, command); + let request = app + .source_symbols + .request + .clone() + .expect("actual source query started"); + assert_eq!(app.source_diagram_job, Some(request.job)); + let completion = source_completion(&app, request); + send(&mut app, Message::SourceSymbolsReady(completion)); + let panel = app + .diagram_usages + .as_ref() + .expect("existing guarded chooser opened"); + assert_eq!(panel.context.subject, model_id(2)); + assert_eq!(panel.context.document, app.canvas_document); + assert_eq!(panel.context.revision, app.document.revision()); + assert_eq!(app.diagram_lookup_in_flight, Some(panel.token)); + assert!(!app.source_navigation_available()); + let (_, found) = finish_lookup(&mut app); + assert_eq!(found.surface, usage::Surface::Saved(model_id(8))); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(app.source_symbols.back.is_empty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} diff --git a/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs index 0f9be62f..f98a6978 100644 --- a/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs +++ b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs @@ -512,3 +512,45 @@ async fn details_remount_restores_wheel_offset_before_any_rescroll() { assert_eq!(evidence(&app), before); } } + +#[tokio::test] +async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() { + let mut app = paged_fixture(); + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + let before = evidence(&app); + let interaction = app.source_symbols.interaction; + let camera = app.camera_boundary(); + let allocation = app.allocation_boundary(); + let cursor = app.source_editor.cursor(); + let mut mounted = Mounted::new(&app); + let reports = mounted.wheel(&app); + assert_eq!(reports.len(), 1); + let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else { + panic!("actual wheel must publish the retained preview measurement"); + }; + assert!(offset.is_finite() && *offset > 0.); + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let current = app.text_workspace.diagnostic_publication().unwrap(); + assert_ne!(current.generation(), publication.generation()); + assert_eq!(current.source(), publication.source()); + assert!(app.source_annotation_snapshot().is_some()); + assert!( + !app.source_navigation_available(), + "rename still owns modal input" + ); + for message in reports.iter().cloned() { + let task = app.update(message); + assert_eq!(mounted.execute(&app, task).await, 0); + } + assert_eq!( + app.source_rename.form.as_ref().unwrap().preview_offset, + *offset + ); + assert_eq!(mounted.offset(&app), offset.round()); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.camera_boundary() == camera); + assert!(app.allocation_boundary() == allocation); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(evidence(&app), before); +} diff --git a/crates/oa-desktop/src/app/source_rename/tests.rs b/crates/oa-desktop/src/app/source_rename/tests.rs index 60157ff5..afc3fca7 100644 --- a/crates/oa-desktop/src/app/source_rename/tests.rs +++ b/crates/oa-desktop/src/app/source_rename/tests.rs @@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist assert_ne!(app.source_navigation.label(), "0 matches"); let mut invalid = original_source.clone(); invalid.files.insert(path.clone(), "?".into()); - app.source_diagnostics = source_workspace::lint(&invalid); + app.publish_source_diagnostics(source_workspace::lint(&invalid)); assert!(!app.source_diagnostics.is_empty()); let version = app.source_version; app.lint_requested = Some(version); @@ -839,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { send(&mut app, Message::SourceRenameReady(ready.clone())); } app.source_navigation.open = true; - app.source_symbols.open = true; + app.text_workspace + .show_drawer(Some(crate::app::text_workspace::DrawerTab::References)); match other_modal { 1 => app.hosted.visible = true, 2 => { @@ -866,7 +867,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { } assert!(!app.source_rename.open()); assert!( - app.source_symbols.open && app.source_navigation.open, + app.text_workspace.drawer().is_some() && app.source_navigation.open, "rename Escape does not fall through" ); if pending { @@ -875,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { assert!(!app.source_rename.open()); } assert_eq!(evidence(&app), before); - app.source_symbols.open = false; + app.text_workspace.show_drawer(None); app.source_navigation.open = false; assert!(captured_escape(&app).is_empty()); } diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index 18d394f5..de177f08 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -33,9 +33,29 @@ fn admission_notice(error: query::AdmissionError) -> String { impl App { pub(super) fn source_input_envelope(&self, message: &Message) -> bool { match message { - Message::SourceSymbols(context, _) => { + Message::SourceSymbols(context, event) => { context.session == self.source_workspace.session() && context.path == self.source_workspace.active + && match event { + nav::SymbolEvent::Choose { stamp, visit, .. } => { + self.text_workspace.drawer() + == Some(text_workspace::DrawerTab::References) + && self.source_navigation_available() + && stamp.snapshot == self.source_key() + && *visit == self.text_workspace.drawer_visit() + && self + .source_symbols + .results + .as_ref() + .is_some_and(|results| results.stamp == *stamp) + && self + .source_symbols + .cache + .as_ref() + .is_some_and(|cache| self.source_snapshot_matches(cache)) + } + _ => true, + } } Message::SourceFindClose(context, instance) => { context == &self.source_symbol_context() @@ -106,6 +126,20 @@ impl App { && self.with_source_input(|input| snapshot.matches(input)) } + /// Retained rows remain readable after departure, but are current only while + /// their captured source bytes still match the complete workspace. + pub fn source_results_current(&self) -> bool { + self.source_symbols + .results + .as_ref() + .is_some_and(|results| results.stamp.snapshot == self.source_key()) + && self + .source_symbols + .cache + .as_ref() + .is_some_and(|cache| self.source_snapshot_matches(cache)) + } + pub fn source_navigation_available(&self) -> bool { self.page == Page::Text && !self.source_rename.open() @@ -118,6 +152,7 @@ impl App { && self.model_menu.is_none() && self.diagram_usages.is_none() && !self.reference_input_active() + && self.text_workspace.menu().is_none() && self.architecture.is_none() && !self.behavior.has_pending() } @@ -133,6 +168,12 @@ impl App { )) } + /// Capture the same source-owned query used by the keyboard/menu adapter. + pub(super) fn text_diagram_usages_message(&self) -> Option { + self.source_navigation_available() + .then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages)) + } + pub fn source_reveal_ticket(&self) -> Option { self.source_symbols .reveal @@ -215,7 +256,8 @@ impl App { match event { nav::SymbolEvent::DiagramUsages => { if self.source_dirty() || !self.source_workspace.applied { - self.source_symbols.open = true; + self.text_workspace + .show_drawer(Some(text_workspace::DrawerTab::References)); self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into()); return Task::none(); } @@ -227,21 +269,28 @@ impl App { .map(|request| request.job); return task; } - nav::SymbolEvent::Close => { - self.source_symbols.open = false; - self.source_symbols.results = None; - self.source_symbols.notice = None; - } nav::SymbolEvent::Back => { self.source_symbol_back(); // A successful Back needs no empty results panel. Retain an // already open panel, or reveal an actionable return notice. - self.source_symbols.open |= self.source_symbols.notice.is_some(); + if self.source_symbols.notice.is_some() { + self.text_workspace + .show_drawer(Some(text_workspace::DrawerTab::References)); + let refs = self.text_workspace.references(); + return iced::widget::operation::scroll_to( + self.text_workspace.reference_scroll_id(), + iced::widget::scrollable::AbsoluteOffset { + x: 0., + y: refs.scroll_y(), + }, + ); + } } nav::SymbolEvent::Choose { stamp, reference, index, + .. } => { let Some(results) = self.source_symbols.results.clone() else { return Task::none(); @@ -279,7 +328,8 @@ impl App { } fn start_source_query(&mut self, action: query::Action) -> Task { - self.source_symbols.open = true; + self.text_workspace + .show_drawer(Some(text_workspace::DrawerTab::References)); if !nav::interaction::is_current( &self.source_symbols.raw_interaction, self.source_symbols.raw_interaction.load(Ordering::SeqCst), @@ -300,7 +350,7 @@ impl App { self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) { Some(serial) => serial, None => { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); self.source_symbols.back.clear(); self.source_symbols.cache = None; 0 @@ -340,9 +390,12 @@ impl App { }; self.source_symbols.in_flight = Some(request.job); self.source_symbols.request = Some(request.clone()); + self.source_symbols.display_request = Some(request.clone()); + self.text_workspace + .begin_references(request.stamp, request.path.clone()); self.source_symbols.results = None; self.source_symbols.notice = None; - Task::perform( + let worker = Task::perform( async move { let worker_request = request.clone(); let result = tokio::task::spawn_blocking(move || { @@ -373,7 +426,14 @@ impl App { nav::Completion { request, result } }, Message::SourceSymbolsReady, - ) + ); + Task::batch([ + worker, + iced::widget::operation::scroll_to( + self.text_workspace.reference_scroll_id(), + iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. }, + ), + ]) } pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task { @@ -386,11 +446,11 @@ impl App { if diagram_query { self.source_diagram_job = None; } - let wanted = self + let wanted_navigation = self .source_symbols .request .take() - .is_some_and(|current| current.job == request.job) + .is_some_and(|current| current == request) && request.interaction == self.source_symbols.interaction && request.stamp.snapshot == self.source_key() && nav::interaction::is_current( @@ -400,15 +460,34 @@ impl App { && request.path == self.source_workspace.active && request.cursor == self.source_editor.cursor() && self.source_navigation_available(); - if !wanted { + let wanted_display = self + .source_symbols + .display_request + .as_ref() + .is_some_and(|current| current == &request) + && request.stamp.snapshot == self.source_key(); + if !wanted_display { return Task::none(); } match completion.result { - Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => { + Ok((snapshot, results)) + if self.source_snapshot_matches(&snapshot) + && results.stamp == request.stamp + && results.action == request.action => + { self.source_symbols.cache = Some(snapshot); self.source_symbols.results = Some(results.clone()); - if diagram_query { - if results.disposition == query::Disposition::Direct + self.text_workspace.install_reference_files( + results.stamp, + results + .definitions + .iter() + .map(|row| row.path.clone()) + .chain(results.references.iter().map(|row| row.path.clone())), + ); + if diagram_query && wanted_navigation { + if wanted_navigation + && results.disposition == query::Disposition::Direct && results.scope.validated && results.omitted_definitions == 0 && let [row] = results.definitions.as_slice() @@ -419,7 +498,7 @@ impl App { && crate::diagram_usage::alias(self.project(), identity.id()) == row.declaration.alias { - self.source_symbols.open = false; + self.text_workspace.show_drawer(None); return self.diagram_usage_event(crate::diagram_usage::Event::Open( crate::diagram_usage::Context { document: self.canvas_document, @@ -431,7 +510,8 @@ impl App { self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into()); return Task::none(); } - if results.disposition == query::Disposition::Direct + if wanted_navigation + && results.disposition == query::Disposition::Direct && let Some(row) = results.definitions.first() { self.source_symbol_jump(&row.path, row.position, row.end_position); diff --git a/crates/oa-desktop/src/app/source_symbols/tests.rs b/crates/oa-desktop/src/app/source_symbols/tests.rs index 92b9ed5f..3bab7195 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; use crate::source_workspace::Workspace; use oa_core::source_project::SourceBundle; use std::collections::BTreeMap; @@ -37,6 +38,7 @@ fn fixture() -> App { let mut app = App::with_project(Project::empty()); app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap(); app.source_workspace = Workspace::new(app.document.bundle(), true); + app.publish_source_diagnostics(app.document.compiled().diagnostics.clone()); app.selected = app.project().id; app.refresh_draft(); app.page = Page::Text; @@ -50,6 +52,28 @@ fn fixture() -> App { app } +fn select_effective_pump(app: &mut App, line: bool) -> String { + let source = app.source_workspace.text(); + let offset = source.find("source @pump").unwrap() + "source @pu".len(); + app.source_editor.move_to(text_editor::Cursor { + position: position(source, offset), + selection: None, + }); + send( + app, + Message::SourceEdit(if line { + text_editor::Action::SelectLine + } else { + text_editor::Action::SelectWord + }), + ); + let selected = app.source_editor.selection().unwrap(); + assert!(selected.contains("pump")); + let raw = app.source_editor.cursor(); + assert_eq!(raw.selection, Some(raw.position)); + selected +} + fn send(app: &mut App, message: Message) { // Dropping the returned Task does not poll its worker/lint future. Completion // below is deterministic and still traverses the real App message handler. @@ -61,6 +85,23 @@ fn symbol(app: &mut App, event: nav::SymbolEvent) { send(app, message); } +fn close_drawer(app: &mut App) { + let message = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())); + send(app, message); +} + +fn choice(app: &App, stamp: query::QueryStamp, reference: bool, index: usize) -> Message { + app.source_symbol_message(nav::SymbolEvent::Choose { + stamp, + visit: app.text_workspace.drawer_visit(), + reference, + index, + }) +} +fn references_visible(app: &App) -> bool { + app.text_workspace.drawer() == Some(DrawerTab::References) +} + fn show(app: &mut App, path: &str) { send(app, Message::SourceFile(path.into())); reveal(app); @@ -99,6 +140,9 @@ fn begin(app: &mut App, action: query::Action) -> nav::Request { .clone() .expect("request admitted"); assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); + assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp)); + assert!(references_visible(app)); request } @@ -165,14 +209,8 @@ fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged() "references require a choice" ); let second = uses.references[1].clone(); - symbol( - &mut app, - nav::SymbolEvent::Choose { - stamp: uses.stamp, - reference: true, - index: 1, - }, - ); + let message = choice(&app, uses.stamp, true, 1); + send(&mut app, message); reveal(&mut app); assert_eq!(app.source_workspace.active, USES); assert_eq!( @@ -213,14 +251,8 @@ fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_v results.definitions[index].membership, query::Membership::Detached ); - symbol( - &mut app, - nav::SymbolEvent::Choose { - stamp: results.stamp, - reference: false, - index, - }, - ); + let message = choice(&app, results.stamp, false, index); + send(&mut app, message); reveal(&mut app); assert_eq!(app.source_workspace.active, "detached.arcadia"); assert_eq!(app.source_workspace.bundle(), loaded); @@ -268,14 +300,8 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() { .iter() .position(|row| row.declaration.alias == "pump") .unwrap(); - symbol( - &mut app, - nav::SymbolEvent::Choose { - stamp: results.stamp, - reference: false, - index, - }, - ); + let message = choice(&app, results.stamp, false, index); + send(&mut app, message); reveal(&mut app); symbol(&mut app, nav::SymbolEvent::Back); reveal(&mut app); @@ -300,11 +326,12 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() { } #[test] -fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes() { +fn completion_retains_current_display_but_rejects_navigation_after_context_changes() { for change in 0..7 { let mut app = fixture(); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request.clone()); + let expected = ready.result.as_ref().unwrap().1.clone(); let accepted = app.document.snapshot(); match change { 0 => show(&mut app, "project.arcadia"), @@ -328,23 +355,38 @@ fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes let after = app.source_workspace.bundle(); let active = app.source_workspace.active.clone(); let cursor = app.source_editor.cursor(); + let drawer = app.text_workspace.drawer(); send(&mut app, Message::SourceSymbolsReady(ready)); assert!( app.source_symbols.in_flight.is_none(), "case {change}: release worker slot" ); - assert!( - app.source_symbols.results.is_none(), - "case {change}: stale result surfaced" + if matches!(change, 1 | 2) { + assert!( + app.source_symbols.results.is_none(), + "case {change}: changed source/session cannot own display" + ); + } else { + assert!( + Arc::ptr_eq(app.source_symbols.results.as_ref().unwrap(), &expected), + "case {change}: canceled navigation still owns current-source display" + ); + } + assert_eq!( + app.text_workspace.drawer(), + drawer, + "completion cannot select a tab" ); assert!(app.source_symbols.back.is_empty()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, active); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!(app.source_workspace.bundle(), after); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); } } - #[test] fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() { let mut app = fixture(); @@ -368,25 +410,41 @@ fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_ne } #[test] -fn close_during_busy_hides_panel_and_completion_does_not_reopen_it() { +fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation() { let mut app = fixture(); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request.clone()); - assert!(app.source_symbols.open); - symbol(&mut app, nav::SymbolEvent::Close); - assert!(!app.source_symbols.open); + let expected = ready.result.as_ref().unwrap().1.clone(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + assert!(references_visible(&app)); + close_drawer(&mut app); + assert!(app.text_workspace.drawer().is_none()); assert_eq!(app.source_symbols.in_flight, Some(request.job)); assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); send(&mut app, Message::SourceSymbolsReady(ready)); - assert!(!app.source_symbols.open); + assert!(app.text_workspace.drawer().is_none()); assert!(app.source_symbols.in_flight.is_none()); - assert!(app.source_symbols.results.is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.back.is_empty()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); let next = begin(&mut app, query::Action::References); assert_ne!(next.job, request.job); - assert!(app.source_symbols.open); + assert!(references_visible(&app)); + assert!( + app.source_symbols.results.is_none(), + "explicit new query replaces prior display ownership" + ); } - #[test] fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() { let mut app = fixture(); @@ -420,7 +478,7 @@ fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() } #[test] -fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources() { +fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() { for pending in [false, true] { let mut app = fixture(); let accepted = app.document.snapshot(); @@ -428,32 +486,42 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources let cursor = app.source_editor.cursor(); let request = begin(&mut app, query::Action::References); let ready = completion(&app, request.clone()); + let expected = ready.result.as_ref().unwrap().1.clone(); if !pending { send(&mut app, Message::SourceSymbolsReady(ready.clone())); - assert!(app.source_symbols.results.is_some()); } let results = app.source_symbols.results.clone(); app.source_navigation.open = true; + let visit = app.text_workspace.drawer_visit(); send(&mut app, Message::Escape); - assert!(app.source_symbols.open && !app.source_navigation.open); + assert!(references_visible(&app) && !app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); match (&results, &app.source_symbols.results) { (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), (None, None) => {} _ => panic!("Find Escape must retain underlying results"), } + assert!(app.source_symbols.request.is_none()); + reveal(&mut app); send(&mut app, Message::Escape); - assert!(!app.source_symbols.open && !app.source_navigation.open); - assert!(app.source_symbols.results.is_none()); - assert!(app.source_symbols.notice.is_none()); + assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open); + assert_eq!(app.source_symbols.results.is_some(), !pending); assert!(app.source_symbols.request.is_none()); + // Explicit Escape may restore editor focus; consume that ticket before + // delivering completion so any later focus request is discriminated. + reveal(&mut app); if pending { assert_eq!(app.source_symbols.in_flight, Some(request.job)); send(&mut app, Message::SourceSymbolsReady(ready)); } assert!(app.source_symbols.in_flight.is_none()); - assert!(!app.source_symbols.open); - assert!(app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, USES); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!(app.source_workspace.bundle(), raw); @@ -462,7 +530,6 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources assert!(!app.document.can_undo() && !app.document.can_redo()); } } - #[test] fn escape_dismisses_modal_before_underlying_source_results() { let mut app = fixture(); @@ -476,14 +543,17 @@ fn escape_dismisses_modal_before_underlying_source_results() { app.source_reveal_ticket().is_none(), "modal dismissal must not focus the editor" ); - assert!(app.source_symbols.open); + assert!(references_visible(&app)); assert!(Arc::ptr_eq( app.source_symbols.results.as_ref().unwrap(), &results )); send(&mut app, Message::Escape); - assert!(!app.source_symbols.open); - assert!(app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); assert_eq!(app.source_workspace.bundle(), raw); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); } @@ -496,8 +566,8 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice( let origin = app.source_editor.cursor(); finish(&mut app, query::Action::Definition); reveal(&mut app); - symbol(&mut app, nav::SymbolEvent::Close); - assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + close_drawer(&mut app); + assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some()); if changed { // Use the workspace edit API to give the origin a new generation. show(&mut app, USES); @@ -517,7 +587,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice( let raw = app.source_workspace.bundle(); symbol(&mut app, nav::SymbolEvent::Back); assert_eq!(app.source_workspace.active, USES); - assert_eq!(app.source_symbols.open, changed); + assert_eq!(references_visible(&app), changed); assert_eq!(app.source_symbols.notice.is_some(), changed); reveal(&mut app); if !changed { @@ -529,7 +599,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice( } let mut app = fixture(); symbol(&mut app, nav::SymbolEvent::Back); - assert!(app.source_symbols.open && app.source_symbols.notice.is_some()); + assert!(references_visible(&app) && app.source_symbols.notice.is_some()); } #[test] @@ -540,12 +610,12 @@ fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection() let cursor = app.source_editor.cursor(); app.source_navigation.open = true; app.source_navigation.query = "送水 🚀".into(); - assert!(!app.source_symbols.open); + assert!(!references_visible(&app)); let mut messages = captured_input_subscription_messages(&app); assert_eq!(messages.len(), 1); assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); - assert!(!app.source_navigation.open && !app.source_symbols.open); + assert!(!app.source_navigation.open && !references_visible(&app)); assert_eq!(app.source_navigation.query, "送水 🚀"); assert!(app.source_reveal_ticket().is_some()); reveal(&mut app); @@ -790,6 +860,10 @@ async fn find_close_releases_removed_input_and_guarded_focus_respects_later_inpu /// file dialog or App task is executed by this helper. Require the canvas keyboard /// notification separately, then return every command routed to an Escape owner. fn captured_input_subscription_messages(app: &App) -> Vec { + input_subscription_messages(app, true) +} + +fn input_subscription_messages(app: &App, focused: bool) -> Vec { use iced::advanced::{Layout, Shell, layout, subscription, widget}; use iced::futures::{StreamExt, stream, task::noop_waker_ref}; use iced::keyboard::{self, key::Named}; @@ -805,9 +879,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec { input .as_widget_mut() .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); - tree.state - .downcast_mut::>() - .focus(); + if focused { + tree.state + .downcast_mut::>() + .focus(); + } let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(Named::Escape), modified_key: keyboard::Key::Named(Named::Escape), @@ -829,7 +905,8 @@ fn captured_input_subscription_messages(app: &App) -> Vec { &mut shell, &iced::Rectangle::new(iced::Point::ORIGIN, size), ); - assert!(shell.is_event_captured()); + let captured = shell.is_event_captured(); + assert_eq!(captured, focused); assert!(input_messages.is_empty()); assert!( !tree @@ -840,7 +917,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec { let event = subscription::Event::Interaction { window: iced::window::Id::unique(), event, - status: iced::event::Status::Captured, + status: if captured { + iced::event::Status::Captured + } else { + iced::event::Status::Ignored + }, }; let runtime = tokio::runtime::Builder::new_current_thread() .enable_time() @@ -888,7 +969,9 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi send(&mut app, Message::SourceSymbolsReady(ready.clone())); } let results = app.source_symbols.results.clone(); + let expected = ready.result.as_ref().unwrap().1.clone(); app.source_navigation.open = true; + let visit = app.text_workspace.drawer_visit(); match modal { 0 => {} 1 => app.hosted.visible = true, @@ -908,7 +991,12 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi send(&mut app, messages.remove(0)); if modal != 0 { assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none()); - assert!(app.source_symbols.open && app.source_navigation.open); + assert!(references_visible(&app) && app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!( + app.source_reveal_ticket().is_none(), + "modal Escape cannot steal editor focus" + ); match (&results, &app.source_symbols.results) { (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), (None, None) => {} @@ -919,24 +1007,38 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); } - assert!(app.source_symbols.open && !app.source_navigation.open); + assert!(references_visible(&app) && !app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); match (&results, &app.source_symbols.results) { (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), (None, None) => {} _ => panic!("Find Escape must retain underlying results"), } - let mut messages = captured_input_subscription_messages(&app); - assert_eq!(messages.len(), 1, "references still own the next Escape"); + + assert_eq!(app.source_symbols.results.is_some(), !pending); + assert!(app.source_reveal_ticket().is_some()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + // One more actual Escape hides the remaining drawer. Both status + // routes emit exactly one App message; no second bridge is needed. + let mut messages = input_subscription_messages(&app, false); + assert_eq!(messages.len(), 1); assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); - assert!(!app.source_symbols.open && !app.source_navigation.open); - assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none()); + assert!(!references_visible(&app) && !app.source_navigation.open); + assert!(app.source_symbols.request.is_none()); + reveal(&mut app); if pending { assert_eq!(app.source_symbols.in_flight, Some(request.job)); send(&mut app, Message::SourceSymbolsReady(ready)); } assert!(app.source_symbols.in_flight.is_none()); - assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, USES); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!(app.source_workspace.bundle(), raw); @@ -953,7 +1055,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi #[test] fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() { let mut app = fixture(); - app.source_symbols.open = true; + app.text_workspace.show_drawer(Some(DrawerTab::References)); let project = app.project().clone(); assert!( app.behavior @@ -983,7 +1085,7 @@ fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_ro assert!(app.architecture.as_ref().unwrap().inline()); // A retained Text panel must not add a second route to inline architecture. app.page = Page::Text; - app.source_symbols.open = true; + app.text_workspace.show_drawer(Some(DrawerTab::References)); let messages = captured_input_subscription_messages(&app); assert_eq!(messages.len(), 1); assert!(matches!( @@ -1203,7 +1305,16 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message]) .collect(); let results = app.source_symbols.results.clone(); let cache = app.source_symbols.cache.clone(); - let panel = (app.source_symbols.open, app.source_symbols.notice.clone()); + let panel = ( + app.text_workspace.drawer(), + app.source_symbols.notice.clone(), + ); + let display_request = app.source_symbols.display_request.clone(); + let reference_presentation = ( + app.text_workspace.references().stamp(), + app.text_workspace.drawer_visit(), + app.text_workspace.references().scroll_y(), + ); let key = app.source_key(); let active = app.source_workspace.active.clone(); let cursor = app.source_editor.cursor(); @@ -1268,9 +1379,21 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message]) back ); assert_eq!( - (app.source_symbols.open, app.source_symbols.notice.clone()), + ( + app.text_workspace.drawer(), + app.source_symbols.notice.clone() + ), panel ); + assert_eq!(app.source_symbols.display_request, display_request); + assert_eq!( + ( + app.text_workspace.references().stamp(), + app.text_workspace.drawer_visit(), + app.text_workspace.references().scroll_y() + ), + reference_presentation + ); match (&results, &app.source_symbols.results) { (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), (None, None) => {} @@ -1315,12 +1438,8 @@ fn stale_envelope_trial(reset: bool) { let old_messages = [ app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)), app.source_symbol_message(nav::SymbolEvent::Back), - app.source_symbol_message(nav::SymbolEvent::Close), - app.source_symbol_message(nav::SymbolEvent::Choose { - stamp: old_results.stamp, - reference: false, - index: 0, - }), + Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())), + choice(&app, old_results.stamp, false, 0), ]; if reset { let project = app.project().clone(); @@ -1380,9 +1499,9 @@ fn stale_envelope_trial(reset: bool) { // Replay once more with no busy-slot fallback and visible current results. assert_stale_envelopes_preserve_current(&mut app, &old_messages); let interaction = app.source_symbols.interaction; - symbol(&mut app, nav::SymbolEvent::Close); + close_drawer(&mut app); assert_ne!(app.source_symbols.interaction, interaction); - assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some()); let next = begin(&mut app, query::Action::References); assert_ne!( next.job, current.job, @@ -1535,12 +1654,332 @@ fn process_memory() -> Option<(usize, usize)> { (ok != 0).then_some((counters.working_set, counters.peak_working_set)) } +#[test] +fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or_focus() { + for destination in [None, Some(DrawerTab::Problems), Some(DrawerTab::Status)] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + let expected = ready.result.as_ref().unwrap().1.clone(); + assert_eq!(expected.disposition, query::Disposition::Direct); + let event = destination.map_or(TextEvent::HideDrawer(app.text_drawer_context()), |tab| { + TextEvent::ShowDrawer(app.text_drawer_context(), tab) + }); + send(&mut app, Message::TextWorkspace(event)); + assert_eq!(app.text_workspace.drawer(), destination); + assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + let visit = app.text_workspace.drawer_visit(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert_eq!(app.text_workspace.drawer(), destination); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp)); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + { + let captured = Message::TextWorkspace(TextEvent::ShowDrawer( + app.text_drawer_context(), + DrawerTab::References, + )); + send(&mut app, captured); + }; + assert!(references_visible(&app)); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.in_flight.is_none() && app.source_symbols.request.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_editor.cursor(), cursor); + } +} + +#[test] +fn same_worker_with_changed_request_envelope_cannot_publish_or_navigate() { + for changed in 0..7 { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let mut ready = completion(&app, request.clone()); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + match changed { + 0 => ready.request.stamp.serial += 1, + 1 => ready.request.path = DEFINITIONS.into(), + 2 => ready.request.offset += 1, + 3 => ready.request.cursor.selection = None, + 4 => ready.request.interaction = Id::new_v4(), + 5 => ready.request.raw_interaction += 1, + _ => ready.request.action = query::Action::References, + } + assert_eq!(ready.request.job, request.job); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!( + app.source_symbols.in_flight.is_none(), + "worker completed in case {changed}" + ); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn matching_old_worker_cannot_replace_a_newer_display_owner() { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let old = begin(&mut app, query::Action::Definition); + let ready = completion(&app, old.clone()); + let mut latest = old.clone(); + latest.stamp.serial += 1; + latest.job = Id::new_v4(); + // Isolate display ownership from both worker ownership and navigation intent. + app.source_interaction(); + app.source_symbols.display_request = Some(latest.clone()); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&latest)); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced() { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + let accepted = app.document.snapshot(); + { + let captured = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())); + send(&mut app, captured); + }; + // Deliberately violate the normal edit API's key advancement: the exact + // source witness must still reject stale bytes instead of trusting the key. + app.source_workspace + .files + .get_mut(USES) + .unwrap() + .text + .push_str("\r\n// changed bytes"); + assert_eq!(app.source_key(), request.stamp.snapshot); + let changed = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.bundle(), changed); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); +} + +#[test] +fn popup_escape_has_no_duplicate_bridge_and_dismissal_preserves_underlying_find_and_drawer() { + for line in [false, true] { + for captured_find_escape in [false, true] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let results = finish(&mut app, query::Action::References); + let selected = select_effective_pump(&mut app, line); + let cursor = app.source_editor.cursor(); + let group = TextEvent::ToggleFile { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + path: USES.into(), + }; + send(&mut app, Message::TextWorkspace(group)); + let visit = app.text_workspace.drawer_visit(); + send( + &mut app, + Message::TextWorkspace(TextEvent::ReferenceScrolled { + stamp: results.stamp, + visit, + y: 96., + }), + ); + assert!(app.text_workspace.references().collapsed(USES)); + assert_eq!(app.text_workspace.references().scroll_y(), 96.); + send(&mut app, Message::SourceNavigation(nav::Event::Open(false))); + app.source_navigation.query = "Retain Find 雨".into(); + let open = app.text_menu_message(iced::Point::new(30., 30.)); + send(&mut app, open); + let menu = app.text_workspace_menu().expect("Text popup opened"); + assert!( + captured_input_subscription_messages(&app).is_empty(), + "Popup owns captured Escape; a bridge Escape would also close its underlying UI" + ); + // The popup itself publishes this tokened dismissal on Escape. + send(&mut app, menu.on_close); + assert!(app.text_workspace.menu().is_none()); + assert!(references_visible(&app) && app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + + let mut next = input_subscription_messages(&app, captured_find_escape); + assert_eq!( + next.len(), + 1, + "exactly one message for either Escape status" + ); + assert!(matches!(next[0], Message::Escape)); + send(&mut app, next.remove(0)); + assert!(references_visible(&app) && !app.source_navigation.open); + assert_eq!( + app.text_workspace.drawer_visit(), + visit, + "closing Find does not replace the drawer visit" + ); + assert_eq!(app.source_navigation.query, "Retain Find 雨"); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert!(app.text_workspace.references().collapsed(USES)); + assert_eq!(app.text_workspace.references().scroll_y(), 96.); + assert!(app.source_reveal_ticket().is_some() && app.source_jump.is_none()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + + let mut last = input_subscription_messages(&app, false); + assert_eq!(last.len(), 1); + assert!(matches!(last[0], Message::Escape)); + send(&mut app, last.remove(0)); + assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open); + assert_ne!(app.text_workspace.drawer_visit(), visit); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert!(app.text_workspace.references().collapsed(USES)); + assert_eq!(app.text_workspace.references().scroll_y(), 96.); + assert_eq!(app.source_navigation.query, "Retain Find 雨"); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + assert!(captured_input_subscription_messages(&app).is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(app.source_symbols.back.is_empty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + +#[test] +fn retained_choices_cannot_replay_after_hide_or_references_problems_references() { + for reference in [false, true] { + for hide in [false, true] { + let mut app = fixture(); + let results = finish(&mut app, query::Action::References); + let index = usize::from(reference); + let old = choice(&app, results.stamp, reference, index); + let old_visit = app.text_workspace.drawer_visit(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let event = if hide { + TextEvent::HideDrawer(app.text_drawer_context()) + } else { + TextEvent::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems) + }; + send(&mut app, Message::TextWorkspace(event)); + assert_ne!(app.text_workspace.drawer_visit(), old_visit); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert_stale_envelopes_preserve_current(&mut app, std::slice::from_ref(&old)); + { + let captured = Message::TextWorkspace(TextEvent::ShowDrawer( + app.text_drawer_context(), + DrawerTab::References, + )); + send(&mut app, captured); + }; + assert_ne!(app.text_workspace.drawer_visit(), old_visit); + assert_eq!( + app.source_key(), + results.stamp.snapshot, + "same source isolates the visit guard" + ); + assert_stale_envelopes_preserve_current(&mut app, &[old]); + let fresh = choice(&app, results.stamp, reference, index); + send(&mut app, fresh); + assert!( + app.source_symbols.reveal.is_some(), + "freshly captured reopened choice remains usable" + ); + assert_eq!(app.source_symbols.back.len(), 1); + reveal(&mut app); + let (path, span) = if reference { + let row = &results.references[index]; + (&row.path, row.span) + } else { + let row = &results.definitions[index]; + (&row.path, row.span) + }; + assert_eq!(&app.source_workspace.active, path); + assert_eq!( + app.source_editor.cursor(), + selection(&raw.files[path], span.start, span.end, false) + ); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + #[test] fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() { for modal in 0..5 { let mut app = fixture(); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request); + let expected = ready.result.as_ref().unwrap().1.clone(); let accepted = app.document.snapshot(); let raw = app.source_workspace.bundle(); let cursor = app.source_editor.cursor(); @@ -1572,7 +2011,11 @@ fn combined_modal_completion_releases_worker_without_navigation_or_model_changes send(&mut app, Message::SourceSymbolsReady(ready)); assert!(app.source_symbols.in_flight.is_none(), "modal {modal}"); assert!(app.source_symbols.request.is_none()); - assert!(app.source_symbols.results.is_none()); + // Current-source display data may finish behind a modal; it grants no navigation. + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); assert!(app.source_symbols.back.is_empty()); assert!(app.source_reveal_ticket().is_none()); assert_eq!(app.source_workspace.active, USES); @@ -1623,6 +2066,7 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil .move_to(selection(text, offset, offset + alias.len(), false)); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request); + let expected = ready.result.as_ref().unwrap().1.clone(); app.page = Page::Diagram; send(&mut app, Message::FieldReferenceOpen(open)); assert!(app.reference_input_active(), "{}", app.status); @@ -1635,7 +2079,11 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil let cursor = app.source_editor.cursor(); send(&mut app, Message::SourceSymbolsReady(ready)); assert!(app.source_symbols.in_flight.is_none()); - assert!(app.source_symbols.results.is_none()); + // Current-source display data may finish behind a modal; it grants no navigation. + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); assert!(app.source_reveal_ticket().is_none()); assert!( app.reference_input_active(), @@ -1645,3 +2093,31 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil assert_eq!(app.source_workspace.bundle(), raw); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); } + +#[test] +fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() { + for path in [USES, DEFINITIONS] { + let mut app = fixture(); + let results = finish(&mut app, query::Action::References); + assert!(app.source_results_current()); + #[cfg(debug_assertions)] + assert!(!crate::views::captured_source_reference_stale_summary(&app)); + let captured = choice(&app, results.stamp, true, 0); + let key = app.source_key(); + // Isolate byte authority from revision authority in both the displayed + // and an inactive buffer. Views share this exact currentness decision. + app.source_workspace + .files + .get_mut(path) + .unwrap() + .text + .push_str("\r\n// exact bytes changed"); + assert_eq!(app.source_key(), key); + assert!(!app.source_results_current()); + #[cfg(debug_assertions)] + assert!(crate::views::captured_source_reference_stale_summary(&app)); + app.queue_source_reveal(app.source_editor.cursor(), false); + assert!(app.source_reveal_ticket().is_some()); + assert_stale_envelopes_preserve_current(&mut app, &[captured]); + } +} diff --git a/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs b/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs index 0112759c..19ccc1f3 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs @@ -20,7 +20,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se send(&mut app, messages.remove(0)); assert!(!app.source_navigation.open); assert_eq!(app.source_navigation.query, "retain 雪"); - assert!(app.source_symbols.open); + assert!(references_visible(&app)); assert!(Arc::ptr_eq( app.source_symbols.results.as_ref().unwrap(), &results @@ -40,8 +40,15 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se assert_eq!(messages.len(), 1); assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); - assert!(!app.source_symbols.open); - assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert_eq!( + app.source_symbols.notice.as_deref(), + Some("Keep this reference notice") + ); reveal(&mut app); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!( diff --git a/crates/oa-desktop/src/app/text_workspace.rs b/crates/oa-desktop/src/app/text_workspace.rs new file mode 100644 index 00000000..7efb23e7 --- /dev/null +++ b/crates/oa-desktop/src/app/text_workspace.rs @@ -0,0 +1,686 @@ +//! Text-only presentation and captured command envelopes. Source/model authority +//! remains in the existing App controllers; dispatch this envelope before the +//! generic source_interaction invalidation in App::update. +use super::{App, Message}; +use crate::{context_menu, source_navigation as nav}; +use iced::{ + Point, Task, + widget::{self, text_editor::Cursor}, +}; +use nav::query::{QueryStamp, SnapshotKey}; +use oa_core::{Id, source_project::SourceLocation}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DrawerTab { + References, + Problems, + Status, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + Find, + GoToLine, + Definition, + References, + RenameAlias, + Back, + UndoText, + RedoText, + UndoModel, + RedoModel, + UndoRename, + RevertFile, + ShowProblems, + ShowStatus, + ShowDiagramUsages, +} +impl Command { + fn shortcut(self) -> Option<&'static str> { + match self { + Self::Find => Some("Ctrl F"), + Self::GoToLine => Some("Ctrl G"), + Self::Definition => Some("F12"), + Self::References => Some("Shift F12"), + Self::RenameAlias => Some("F2"), + Self::Back => Some("Alt ←"), + Self::UndoText => Some("Ctrl Z"), + Self::RedoText => Some("Ctrl Y"), + _ => None, + } + } + fn label(self) -> &'static str { + match self { + Self::Find => "Find in file", + Self::GoToLine => "Go to line", + Self::Definition => "Go to definition", + Self::References => "Find references", + Self::RenameAlias => "Rename source alias", + Self::Back => "Back in source", + Self::UndoText => "Undo text edit", + Self::RedoText => "Redo text edit", + Self::UndoModel => "Undo model change", + Self::RedoModel => "Redo model change", + Self::UndoRename => "Undo source rename", + Self::RevertFile => "Revert current file", + Self::ShowProblems => "Source problems", + Self::ShowStatus => "Source status", + Self::ShowDiagramUsages => "Show diagram usages", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CommandContext { + document: Id, + document_revision: u64, + source: SnapshotKey, + path: String, + cursor: Cursor, + interaction: Id, +} +#[derive(Debug, Clone)] +pub struct CommandRequest { + context: CommandContext, + command: Command, +} +#[derive(Debug, Clone)] +pub struct MenuRequest { + context: CommandContext, + anchor: Point, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiagnosticPublication { + generation: Id, + source: SnapshotKey, +} +impl DiagnosticPublication { + pub fn generation(self) -> Id { + self.generation + } + pub fn source(self) -> SnapshotKey { + self.source + } + pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget { + DiagnosticTarget { + publication: self, + visit, + location, + } + } +} +#[derive(Debug, Clone)] +pub struct DiagnosticTarget { + publication: DiagnosticPublication, + visit: Id, + location: SourceLocation, +} + +/// Authority captured by the rendered drawer, independent of its source rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DrawerContext { + pub document: Id, + pub session: Id, + pub visit: Id, +} + +#[derive(Debug, Clone)] +pub enum Event { + ShowDrawer(DrawerContext, DrawerTab), + HideDrawer(DrawerContext), + ResizeDrawer(DrawerContext, f32), + ToggleFile { + stamp: QueryStamp, + visit: Id, + path: String, + }, + ReferenceScrolled { + stamp: QueryStamp, + visit: Id, + y: f32, + }, + Command(CommandRequest), + OpenMenu(MenuRequest), + DismissMenu(Id), + Invoke { + token: Id, + command: Command, + }, + Diagnostic(DiagnosticTarget), +} + +#[derive(Debug, Clone)] +struct FrozenEntry { + command: Command, + action: Option, +} +#[derive(Debug, Clone)] +enum Action { + Dispatch(Box), + Drawer(DrawerTab), +} +#[derive(Debug, Clone)] +pub struct Menu { + token: Id, + popup_token: u64, + anchor: Point, + context: CommandContext, + entries: Vec, +} +impl Menu { + pub fn token(&self) -> Id { + self.token + } +} + +#[derive(Debug)] +pub struct ReferencePresentation { + stamp: Option, + origin: String, + files: BTreeSet, + installed: bool, + collapsed_files: BTreeSet, + 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 { + 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, + drawer_visit: Id, + drawer_height: f32, + references: ReferencePresentation, + menu: Option, + diagnostic_publication: Option, +} +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 { + 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 { + 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, + ) { + 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) { + self.drawer = drawer; + self.drawer_visit = Id::new_v4(); + } + fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool { + let refs = &mut self.references; + if self.drawer != Some(DrawerTab::References) + || self.drawer_visit != visit + || refs.stamp != Some(stamp) + || !refs.files.contains(&path) + { + return false; + } + if !refs.collapsed_files.remove(&path) { + refs.collapsed_files.insert(path); + } + // Collapsing a group can replace a pressed row at the same position. + self.drawer_visit = Id::new_v4(); + true + } + fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) { + let refs = &mut self.references; + if self.drawer == Some(DrawerTab::References) + && refs.stamp == Some(stamp) + && self.drawer_visit == visit + && y.is_finite() + && y >= 0. + { + refs.scroll_y = y; + } + } +} + +impl App { + pub fn text_drawer_context(&self) -> DrawerContext { + DrawerContext { + document: self.canvas_document, + session: self.source_workspace.session(), + visit: self.text_workspace.drawer_visit(), + } + } + fn text_command_context(&self) -> CommandContext { + CommandContext { + document: self.canvas_document, + document_revision: self.document.revision(), + source: self.source_key(), + path: self.source_workspace.active.clone(), + cursor: self.source_editor.cursor(), + interaction: self.source_symbols.interaction, + } + } + pub fn text_command_message(&self, command: Command) -> Message { + Message::TextWorkspace(Event::Command(CommandRequest { + context: self.text_command_context(), + command, + })) + } + pub fn text_menu_message(&self, anchor: Point) -> Message { + Message::TextWorkspace(Event::OpenMenu(MenuRequest { + context: self.text_command_context(), + anchor, + })) + } + pub fn text_workspace_menu(&self) -> Option> { + 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 { + 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> { + 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 { + // 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 { + match event { + Event::OpenMenu(request) => { + if request.context != self.text_command_context() + || !self.source_navigation_available() + || self.text_workspace.menu.is_some() + || !request.anchor.x.is_finite() + || !request.anchor.y.is_finite() + { + return Task::none(); + } + let Some(popup_token) = self.menu_sequence.checked_add(1) else { + return Task::none(); + }; + self.text_presentation_boundary(Event::OpenMenu(request.clone())); + self.source_interaction(); + let commands = [ + Command::Find, + Command::GoToLine, + Command::Definition, + Command::References, + Command::RenameAlias, + Command::Back, + Command::UndoText, + Command::RedoText, + Command::UndoRename, + Command::UndoModel, + Command::RedoModel, + Command::RevertFile, + Command::ShowProblems, + Command::ShowStatus, + Command::ShowDiagramUsages, + ]; + let entries = commands + .into_iter() + .filter_map(|command| { + let action = self.text_command_action(command); + if command == Command::ShowDiagramUsages && action.is_none() { + None + } else { + Some(FrozenEntry { command, action }) + } + }) + .collect(); + self.menu_sequence = popup_token; + self.text_workspace.menu = Some(Menu { + token: Id::new_v4(), + popup_token, + anchor: request.anchor, + context: self.text_command_context(), + entries, + }); + } + Event::DismissMenu(token) => { + if self + .text_workspace + .menu + .as_ref() + .is_some_and(|menu| menu.token == token) + { + self.text_presentation_boundary(Event::DismissMenu(token)); + self.text_workspace.menu = None; + self.source_interaction(); + } + } + Event::Invoke { token, command } => { + if !self.text_workspace.menu.as_ref().is_some_and(|menu| { + menu.token == token && menu.context == self.text_command_context() + }) { + return Task::none(); + } + // Validate before closing; consume this invocation only once. + // Never resolve a frozen row against a newly generated menu. + let menu = self.text_workspace.menu.take().unwrap(); + let Some(action) = menu + .entries + .into_iter() + .find(|entry| entry.command == command) + .and_then(|entry| entry.action) + else { + return Task::none(); + }; + if self.text_command_action(command).is_none() { + return Task::none(); + } + return self.dispatch_text_action(action); + } + Event::Command(request) => { + if request.context != self.text_command_context() { + return Task::none(); + } + if let Some(action) = self.text_command_action(request.command) { + return self.dispatch_text_action(action); + } + } + Event::ShowDrawer(context, tab) => { + if context != self.text_drawer_context() || !self.source_navigation_available() { + return Task::none(); + } + self.text_presentation_boundary(Event::ShowDrawer(context, tab)); + return self.show_text_drawer(Some(tab)); + } + Event::HideDrawer(context) => { + if context != self.text_drawer_context() || !self.source_navigation_available() { + return Task::none(); + } + self.text_presentation_boundary(Event::HideDrawer(context)); + return self.show_text_drawer(None); + } + Event::ResizeDrawer(context, height) => { + if context == self.text_drawer_context() + && self.source_navigation_available() + && height.is_finite() + { + // The view applies the measured editor-allocation bound. + let height = height.clamp(180., 1200.); + if height != self.text_workspace.drawer_height { + self.text_presentation_boundary(Event::ResizeDrawer(context, height)); + self.source_interaction(); + self.text_workspace.drawer_height = height; + } + } + } + Event::ToggleFile { stamp, visit, path } => { + let captured = Event::ToggleFile { + stamp, + visit, + path: path.clone(), + }; + if self.source_navigation_available() + && self.text_workspace.toggle_file(stamp, visit, path) + { + self.text_presentation_boundary(captured); + self.source_interaction(); + } + } + Event::ReferenceScrolled { stamp, visit, y } => { + // Passive geometry must neither invalidate pending navigation + // nor reset the editor identity, even while commands are blocked. + self.text_workspace.reference_scrolled(stamp, visit, y); + } + Event::Diagnostic(target) => { + if !self.source_navigation_available() + || self.text_workspace.drawer() != Some(DrawerTab::Problems) + || self.text_workspace.drawer_visit() != target.visit + || self.text_workspace.diagnostic_publication != Some(target.publication) + || target.publication.source != self.source_key() + || !self + .source_workspace + .files + .get(&target.location.path) + .is_some_and(|file| valid_span(&file.text, &target.location)) + { + return Task::none(); + } + self.text_presentation_boundary(Event::Diagnostic(target.clone())); + self.source_interaction(); + self.jump_to_source(target.location); + } + } + Task::none() + } + fn dispatch_text_action(&mut self, action: Action) -> Task { + match action { + Action::Dispatch(message) => self.update(*message), + Action::Drawer(tab) => { + self.text_presentation_boundary(Event::ShowDrawer(self.text_drawer_context(), tab)); + self.show_text_drawer(Some(tab)) + } + } + } + /// Local Text presentation changes do not mutate a camera, document or route. + /// After their own authority check, they still supersede pending navigation + /// and allocation through the root's existing input boundary hooks. Source + /// commands use update instead; passive measurements never enter this hook. + fn text_presentation_boundary(&mut self, event: Event) { + let message = Message::TextWorkspace(event); + self.reconcile_pending_diagram_reveal(&message); + self.allocation_before(&message); + } + + pub(super) fn show_text_drawer(&mut self, drawer: Option) -> Task { + 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; diff --git a/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs b/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs new file mode 100644 index 00000000..53b62878 --- /dev/null +++ b/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs @@ -0,0 +1,611 @@ +//! Real lifecycle receipts, with all disk writes confined to unique temp roots. +//! Returned App worker tasks are not polled; completions use the normal captured +//! query input and are delivered deterministically through App::update. +use super::*; +use crate::app::{Page, read_snapshot, write_snapshot}; +use crate::document::Document; +use crate::source_workspace::{LIMITS, Workspace}; +use oa_core::{ + Project, + language::{Diagnostic, Span}, + source_project::{SourceBundle, SourceDiagnostic}, + validation::Severity, +}; +use std::{collections::BTreeMap, path::Path, sync::Arc}; + +const VALID: &str = concat!( + "arcadia 7;\r\n", + "project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n", + " oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n", + "}\r\n", +); + +fn fixture() -> App { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), VALID.into())]), + }; + let mut app = App::with_project(Project::empty()); + // with_project uses in-memory Home metadata and no recovery directory. + assert!(app.recovery.directory.is_none()); + app.document = Document::from_bundle(bundle, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.page = Page::Text; + app.show_source_file(false); + app.publish_source_diagnostics(app.document.compiled().diagnostics.clone()); + app +} +fn send(app: &mut App, message: Message) { + let _ = app.update(message); +} +fn save(app: &mut App, path: &Path) { + let receipt = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + path.to_owned(), + app.disk_project.clone(), + ) + .unwrap(); + send(app, Message::Saved(Ok(Some(receipt)))); +} +fn reveal(app: &mut App) { + if let Some(ticket) = app.source_reveal_ticket() { + send(app, Message::SourceViewport(Some(ticket))); + } +} +fn query(app: &mut App, action: nav::query::Action) -> nav::Completion { + let message = app.source_symbol_message(nav::SymbolEvent::Query(action)); + send(app, message); + let request = app.source_symbols.request.clone().expect("admitted query"); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + let snapshot = Arc::new(app.with_source_input(|input| { + nav::query::admit_owned(input, nav::query::Limits::default()) + .unwrap() + .capture() + .unwrap() + })); + let results = Arc::new( + snapshot + .query(nav::query::Query { + stamp: request.stamp, + path: &request.path, + byte_offset: request.offset, + action, + }) + .unwrap(), + ); + nav::Completion { + request, + result: Ok((snapshot, results)), + } +} +fn seed_navigation(app: &mut App, pending: bool) -> Option { + 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) { + assert_eq!(app.source_symbols.in_flight, job); + assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none()); + assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none()); + assert!(app.text_workspace.references().stamp().is_none()); +} + +#[test] +fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let second_path = second + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + let mut app = fixture(); + save(&mut app, &first_path); + let ready = seed_navigation(&mut app, true).unwrap(); + let job = ready.request.job; + app.publish_source_diagnostics(vec![warning(&app)]); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let old = warning_messages(&app); + let warnings = app.source_diagnostics.clone(); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + let source = app.source_key(); + let accepted = app.document.snapshot(); + let revision = app.document.revision(); + let raw = app.source_workspace.bundle(); + save(&mut app, &second_path); + assert_eq!(app.path.as_deref(), Some(second_path.as_path())); + assert_ne!(app.source_key().workspace, source.workspace); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_eq!(app.document.revision(), revision); + assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID); + assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID); + assert_eq!(app.source_diagnostics, warnings); + let current = app + .text_workspace + .diagnostic_publication() + .expect("identity-only save retained current warnings"); + assert_eq!(current.source(), app.source_key()); + assert_ne!(current.generation(), publication.generation()); + assert_cleared(&app, Some(job)); + for message in old { + let interaction = app.source_symbols.interaction; + send(&mut app, message); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + } + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + for message in warning_messages(&app) { + send(&mut app, message); + assert!( + app.source_symbols.reveal.is_some(), + "fresh primary and related warnings remain actionable" + ); + reveal(&mut app); + } + let cursor = app.source_editor.cursor(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker() +{ + for pending in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory + .path() + .canonicalize() + .unwrap() + .join("broken.arcadia"); + let invalid = format!( + "{} include \"missing.arcadia\";\r\n}}\r\n", + VALID.strip_suffix("}\r\n").unwrap() + ); + std::fs::write(&path, &invalid).unwrap(); + let mut app = fixture(); + let ready = seed_navigation(&mut app, pending); + let job = ready.as_ref().map(|ready| ready.request.job); + app.publish_source_diagnostics(vec![warning(&app)]); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let old = warning_messages(&app); + let old_publication = app.text_workspace.diagnostic_publication().unwrap(); + let old_source = app.source_key(); + let accepted = app.document.snapshot(); + let open_menu = app.text_menu_message(Point::new(20., 20.)); + send(&mut app, open_menu); + assert!(app.text_workspace.menu().is_some()); + let loaded = read_snapshot(path.clone()).unwrap(); + send(&mut app, Message::Loaded(Ok(Some(loaded)))); + assert!(!app.source_workspace.applied); + assert_eq!(app.path.as_deref(), Some(path.as_path())); + assert_ne!(app.source_key().workspace, old_source.workspace); + assert_eq!(app.source_workspace.text(), invalid); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_cleared(&app, job); + assert!( + app.source_diagnostics + .iter() + .any(|issue| issue.diagnostic.severity == Severity::Error) + ); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + assert_eq!(publication.source(), app.source_key()); + assert_ne!(publication.generation(), old_publication.generation()); + for message in old { + send(&mut app, message); + } + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let issue = app + .source_diagnostics + .iter() + .find(|issue| { + issue.path == app.source_workspace.active + && issue.diagnostic.span.end <= invalid.len() + }) + .unwrap(); + let current = app + .text_diagnostic_message(SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, + }) + .unwrap(); + send(&mut app, current); + assert!(app.source_symbols.reveal.is_some()); + reveal(&mut app); + if let Some(ready) = ready { + send(&mut app, Message::SourceSymbolsReady(ready)); + } + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_workspace.text(), invalid); + assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() { + for failed in [false, true] { + let mut app = fixture(); + seed_navigation(&mut app, false); + app.publish_source_diagnostics(vec![warning(&app)]); + let results = app.source_symbols.results.clone().unwrap(); + let cache = app.source_symbols.cache.clone().unwrap(); + let display = app.source_symbols.display_request.clone(); + let back = app + .source_symbols + .back + .iter() + .map(|entry| { + ( + entry.session, + entry.path.clone(), + entry.generation, + entry.cursor, + ) + }) + .collect::>(); + 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::>(), + back + ); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + } +} + +#[test] +fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let second_path = second + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + let mut app = fixture(); + save(&mut app, &first_path); + app.publish_source_diagnostics(vec![warning(&app)]); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let old = warning_messages(&app); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + let draft = format!("{VALID}// unsaved current draft\r\n"); + app.source_workspace + .edit(draft.clone(), app.source_editor.cursor()); + app.show_source_file(false); + assert_ne!(publication.source(), app.source_key()); + let accepted = app.document.snapshot(); + save(&mut app, &second_path); + assert!( + app.text_workspace.diagnostic_publication().is_none(), + "identity-only reset cannot turn an old source span into a current warning" + ); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + assert!( + app.text_diagnostic_message(SourceLocation { + path: app.source_diagnostics[0].path.clone(), + span: app.source_diagnostics[0].diagnostic.span, + }) + .is_none() + ); + for message in old { + send(&mut app, message); + } + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_workspace.text(), draft); + assert!(app.source_dirty()); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_eq!( + std::fs::read_to_string(&second_path).unwrap(), + VALID, + "receipt records accepted bytes while the newer draft remains unsaved" + ); +} + +/// Use an actual pending-save journal and the real recovery worker. The optional +/// reload error is injected only after recovery succeeded, so both receipt arms +/// exercise the same matching target without relying on filesystem races. +fn recover_warning_fixture(stale: bool, reload_error: bool) { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let path = root.join("project.arcadia"); + let journal = root.join(".arcadia-save-project.arcadia.journal"); + let mut app = fixture(); + save(&mut app, &path); + std::fs::create_dir(&journal).unwrap(); + std::fs::write( + journal.join("journal.json"), + serde_json::to_vec(&serde_json::json!({ + "version": 1, "entry": "project.arcadia", "files": [{ + "path": "project.arcadia", "before": true, "write": true + }] + })) + .unwrap(), + ) + .unwrap(); + std::fs::write(journal.join("0.before"), VALID).unwrap(); + std::fs::write( + journal.join("0.after"), + format!("{VALID}// interrupted save\r\n"), + ) + .unwrap(); + let disk = oa_core::source_files::load_project(&path, LIMITS).unwrap(); + app.pending_recovery = crate::app::recovery_target(&disk); + assert_eq!(app.pending_recovery.as_ref(), Some(&path)); + let pending = disk + .diagnostics + .into_iter() + .find(|issue| issue.diagnostic.code == "pending_save") + .unwrap(); + // Current-warning control includes newer unsaved bytes too. Recovery of an + // applied workspace must retain its drafts, not reload over them. + let draft = format!("{VALID}// retained draft\r\n"); + if !stale { + app.source_workspace + .edit(draft.clone(), app.source_editor.cursor()); + app.show_source_file(false); + } + let retained_warning = warning(&app); + app.publish_source_diagnostics(vec![retained_warning.clone(), pending]); + let show = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, show); + let old = warning_messages(&app); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + if stale { + // This is the existing workspace-key departure seam: drafts advance + // the key while a previous diagnostic Vec can still await lint. + app.source_workspace + .edit(draft.clone(), app.source_editor.cursor()); + app.show_source_file(false); + assert_ne!(publication.source(), app.source_key()); + } else { + assert_eq!(publication.source(), app.source_key()); + } + assert!(app.source_workspace.applied && app.source_workspace.has_edits()); + reveal(&mut app); + let accepted = app.document.snapshot(); + let revision = app.document.revision(); + let raw = app.source_workspace.bundle(); + let key = app.source_key(); + let cursor = app.source_editor.cursor(); + let visit = app.text_workspace.drawer_visit(); + send(&mut app, Message::SourceRecover); + assert!( + app.busy, + "normal recovery request admitted with retained applied drafts" + ); + let mut receipt = crate::app::recover_snapshot(path.clone()).unwrap(); + assert_eq!(receipt.target, path); + assert!(receipt.disk.is_ok()); + assert!(!journal.exists(), "real recovery consumed the journal"); + if reload_error { + receipt.disk = Err("injected reload failure after successful recovery".into()); + } + send(&mut app, Message::SourceRecovered(Ok(receipt))); + assert!(!app.busy && app.pending_recovery.is_none()); + assert_eq!(app.source_diagnostics, vec![retained_warning]); + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_workspace.text(), draft); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Problems)); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_eq!(app.document.revision(), revision); + assert!(!app.document.can_undo() && !app.document.can_redo()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), VALID); + assert!(app.status.contains(if reload_error { + "files could not be reloaded" + } else { + "Current model and drafts are retained" + })); + if stale { + assert_eq!( + app.text_workspace.diagnostic_publication(), + Some(publication) + ); + assert!(app.source_annotation_snapshot().is_none()); + assert!( + app.text_diagnostic_message(SourceLocation { + path: app.source_diagnostics[0].path.clone(), + span: app.source_diagnostics[0].diagnostic.span, + }) + .is_none(), + "recovery must not manufacture a fresh stale-warning action" + ); + } else { + let current = app.text_workspace.diagnostic_publication().unwrap(); + assert_eq!(current.source(), key); + assert_ne!(current.generation(), publication.generation()); + assert!(app.source_annotation_snapshot().is_some()); + } + for message in old { + let interaction = app.source_symbols.interaction; + send(&mut app, message); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + } + if !stale { + for message in warning_messages(&app) { + send(&mut app, message); + assert!( + app.source_symbols.reveal.is_some(), + "current primary and related remain live" + ); + reveal(&mut app); + } + } + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn recovery_retains_current_warning_authority_with_drafts_and_reload_failure() { + for reload_error in [false, true] { + recover_warning_fixture(false, reload_error); + } +} + +#[test] +fn recovery_does_not_republish_stale_warnings_after_workspace_key_departure() { + for reload_error in [false, true] { + recover_warning_fixture(true, reload_error); + } +} diff --git a/crates/oa-desktop/src/app/text_workspace/tests.rs b/crates/oa-desktop/src/app/text_workspace/tests.rs new file mode 100644 index 00000000..f15ec7cf --- /dev/null +++ b/crates/oa-desktop/src/app/text_workspace/tests.rs @@ -0,0 +1,877 @@ +use super::*; +use crate::app::Page; +use oa_core::{Project, language::Span}; +use std::sync::Arc; + +fn fixture() -> App { + let mut app = App::with_project(Project::empty()); + app.page = Page::Text; + let diagnostics = app.source_diagnostics.clone(); + app.publish_source_diagnostics(diagnostics); + assert!(app.source_navigation_available()); + app +} +fn send(app: &mut App, event: Event) { + // Exercise the real envelope path; worker/lint futures are deliberately not + // polled here. The parent owns serialized compile/runtime verification. + let _ = app.update(Message::TextWorkspace(event)); +} +fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event { + Event::ToggleFile { + stamp, + visit: app.text_workspace.drawer_visit(), + path, + } +} +fn open(app: &mut App) -> Id { + let message = app.text_menu_message(Point::new(10., 20.)); + let _ = app.update(message); + app.text_workspace.menu().unwrap().token() +} +fn invoke(app: &mut App, token: Id, command: Command) { + send(app, Event::Invoke { token, command }); +} +fn enabled(app: &App, command: Command) -> bool { + app.text_workspace + .menu + .as_ref() + .unwrap() + .entries + .iter() + .find(|entry| entry.command == command) + .is_some_and(|entry| entry.action.is_some()) +} +fn back_entry(app: &App) -> nav::HistoryEntry { + nav::HistoryEntry { + session: app.source_workspace.session(), + path: app.source_workspace.active.clone(), + generation: app.source_workspace.files[&app.source_workspace.active].generation(), + cursor: app.source_editor.cursor(), + } +} +fn stamp(app: &App, serial: u64) -> QueryStamp { + QueryStamp { + snapshot: app.source_key(), + serial, + } +} +fn location(app: &App, start: usize, end: usize) -> SourceLocation { + SourceLocation { + path: app.source_workspace.active.clone(), + span: Span { start, end }, + } +} + +#[test] +fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() { + let mut app = fixture(); + let old_interaction = app.source_symbols.interaction; + let token = open(&mut app); + assert_ne!(old_interaction, app.source_symbols.interaction); + assert_eq!( + app.text_workspace.menu.as_ref().unwrap().context, + app.text_command_context() + ); + assert!(enabled(&app, Command::Find)); + invoke(&mut app, token, Command::Find); + assert!(app.source_navigation.open); + assert!(app.text_workspace.menu().is_none()); + let instance = app.source_find_instance; + invoke(&mut app, token, Command::Find); + assert_eq!( + app.source_find_instance, instance, + "replay must not reopen Find" + ); +} + +#[test] +fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() { + let mut app = fixture(); + let token = open(&mut app); + assert!(!enabled(&app, Command::Back)); + app.source_symbols.back.push(back_entry(&app)); + assert!( + !enabled(&app, Command::Back), + "frozen presentation must remain disabled" + ); + invoke(&mut app, token, Command::Back); + assert_eq!( + app.source_symbols.back.len(), + 1, + "a newly enabled action cannot be substituted" + ); + assert!(app.source_symbols.reveal.is_none()); +} + +#[test] +fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() { + let mut app = fixture(); + let token = open(&mut app); + assert!(enabled(&app, Command::Find)); + app.busy = true; + assert!(enabled(&app, Command::Find)); + invoke(&mut app, token, Command::Find); + assert!(!app.source_navigation.open); + assert!(app.text_workspace.menu().is_none()); +} + +#[test] +fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() { + let mut app = fixture(); + let old = open(&mut app); + send(&mut app, Event::DismissMenu(old)); + let new = open(&mut app); + send(&mut app, Event::DismissMenu(old)); + invoke(&mut app, old, Command::Find); + assert_eq!(app.text_workspace.menu().unwrap().token(), new); + assert!(!app.source_navigation.open); +} + +#[test] +fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() { + for change in 0..6 { + let mut app = fixture(); + let token = open(&mut app); + match change { + 0 => { + let value = format!("{}\n// changed", app.source_workspace.text()); + app.source_workspace.edit(value, app.source_editor.cursor()); + } + 1 => { + let mut cursor = app.source_editor.cursor(); + cursor.position.column += 1; + app.source_editor.move_to(cursor); + } + 2 => app.canvas_document = Id::new_v4(), + 3 => app.source_workspace.active = "other.arcadia".into(), + 4 => app.source_symbols.interaction = Id::new_v4(), + _ => { + let mut bundle = app.document.bundle().clone(); + bundle + .files + .get_mut(&bundle.entry) + .unwrap() + .push_str("\n// accepted revision"); + assert!(app.document.apply_bundle(bundle).unwrap()); + } + } + invoke(&mut app, token, Command::Find); + assert!(!app.source_navigation.open, "stale context case {change}"); + } +} + +#[test] +fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() { + let mut app = fixture(); + let accepted = app.document.bundle().clone(); + let revision = app.document.revision(); + let pending_name = "Pending property draft"; + // Use the actual property input route before source edits coexist. Changing + // Draft::value alone is overwritten by its authoritative raw input buffer. + let _ = app.update(Message::Field("/name".into(), pending_name.into())); + let assert_pending_property = |app: &App| { + let draft = app.draft.as_ref().unwrap(); + assert!(draft.dirty()); + assert_eq!(draft.inputs["/name"].raw, pending_name); + assert_eq!(draft.inputs["/name"].editor.text(), pending_name); + assert_eq!( + draft.materialize().unwrap()["name"].as_str(), + Some(pending_name) + ); + }; + assert_pending_property(&app); + let initial = app.source_workspace.text().to_owned(); + app.source_workspace + .edit(format!("{initial}\n// draft"), app.source_editor.cursor()); + app.show_source_file(false); + assert!(app.source_dirty() && app.draft_dirty()); + let token = open(&mut app); + assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText)); + assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel)); + invoke(&mut app, token, Command::Find); + assert!(app.source_navigation.open); + assert_pending_property(&app); + let token = open(&mut app); + invoke(&mut app, token, Command::UndoText); + assert_eq!(app.source_workspace.text(), initial); + assert_pending_property(&app); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.document.bundle(), &accepted); +} + +#[test] +fn queued_toolbar_request_cannot_retarget_after_source_change() { + let mut app = fixture(); + let request = app.text_command_message(Command::Find); + app.source_workspace + .edit("// changed".into(), app.source_editor.cursor()); + let _ = app.update(request); + assert!(!app.source_navigation.open); +} + +#[test] +fn equal_diagnostic_republication_invalidates_primary_and_related_targets() { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap(); + let previous = app.text_workspace.diagnostic_publication().unwrap(); + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let current = app.text_workspace.diagnostic_publication().unwrap(); + assert_eq!(previous.source(), current.source()); + assert_ne!(previous.generation(), current.generation()); + for message in [primary, related] { + let interaction = app.source_symbols.interaction; + let _ = app.update(message); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.reveal.is_none()); + } +} + +#[test] +fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_some()); + app.source_interaction(); + let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + app.source_workspace + .edit("// newer".into(), app.source_editor.cursor()); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_none()); + assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none()); + app.publish_source_diagnostics(Vec::new()); + let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + app.publish_source_diagnostics(Vec::new()); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_none()); +} + +#[test] +fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + app.source_workspace + .edit("a雨b".into(), app.source_editor.cursor()); + app.show_source_file(false); + app.publish_source_diagnostics(Vec::new()); + let mut missing = location(&app, 0, 1); + missing.path = "missing.arcadia".into(); + for target in [ + location(&app, 3, 2), + location(&app, 0, 6), + location(&app, 2, 4), + location(&app, 1, 3), + missing, + ] { + let message = app.text_diagnostic_message(target).unwrap(); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_none()); + } + let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap(); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_some()); +} + +#[test] +fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() { + let mut app = fixture(); + let first = stamp(&app, 1); + app.text_workspace + .begin_references(first, "b/use.arcadia".into()); + app.text_workspace + .install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]); + assert!(app.text_workspace.references().collapsed("a/use.arcadia")); + assert!(!app.text_workspace.references().collapsed("b/use.arcadia")); + let event = toggle(&app, first, "a/use.arcadia".into()); + send(&mut app, event); + let old_visit = app.text_workspace.drawer_visit(); + let old_scroll_id = app.text_workspace.reference_scroll_id(); + let interaction = app.source_symbols.interaction; + send( + &mut app, + Event::ReferenceScrolled { + stamp: first, + visit: old_visit, + y: 123., + }, + ); + assert_eq!( + app.source_symbols.interaction, interaction, + "scroll is passive" + ); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status); + send(&mut app, captured); + }; + send( + &mut app, + Event::ReferenceScrolled { + stamp: first, + visit: old_visit, + y: 0., + }, + ); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References); + send(&mut app, captured); + }; + assert_eq!(app.text_workspace.references().scroll_y(), 123.); + assert!(!app.text_workspace.references().collapsed("a/use.arcadia")); + send( + &mut app, + Event::ReferenceScrolled { + stamp: first, + visit: old_visit, + y: 0., + }, + ); + assert_eq!(app.text_workspace.references().scroll_y(), 123.); + assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id); + let second = stamp(&app, 2); + app.text_workspace + .begin_references(second, "absent.arcadia".into()); + app.text_workspace + .install_reference_files(first, ["old.arcadia".into()]); + app.text_workspace + .install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]); + assert_eq!(app.text_workspace.references().scroll_y(), 0.); + assert!(!app.text_workspace.references().collapsed("a/use.arcadia")); + assert!(app.text_workspace.references().collapsed("b/use.arcadia")); + let event = toggle(&app, first, "b/use.arcadia".into()); + send(&mut app, event); + assert!(app.text_workspace.references().collapsed("b/use.arcadia")); +} + +#[test] +fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() { + let mut app = fixture(); + open(&mut app); + app.text_workspace.reset(); + assert!(app.text_workspace.menu().is_none()); + assert!(app.text_workspace.diagnostic_publication().is_none()); + assert!(app.text_workspace.drawer().is_none()); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN); + send(&mut app, captured); + }; + assert_eq!(app.text_workspace.drawer_height(), 180.); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX); + send(&mut app, captured); + }; + assert_eq!(app.text_workspace.drawer_height(), 1200.); +} + +#[test] +fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() { + let mut app = fixture(); + let current = stamp(&app, 1); + app.text_workspace + .begin_references(current, "uses.arcadia".into()); + app.text_workspace + .install_reference_files(current, ["uses.arcadia".into()]); + let request = nav::Request { + job: Id::new_v4(), + interaction: app.source_symbols.interaction, + raw_interaction: 0, + stamp: current, + path: app.source_workspace.active.clone(), + offset: 0, + cursor: app.source_editor.cursor(), + action: nav::query::Action::References, + }; + app.source_symbols.request = Some(request.clone()); + app.source_symbols.in_flight = Some(request.job); + let interaction = app.source_symbols.interaction; + let event = toggle(&app, current, "not-a-group.arcadia".into()); + send(&mut app, event); + let old = QueryStamp { + serial: 0, + ..current + }; + let event = toggle(&app, old, "uses.arcadia".into()); + send(&mut app, event); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN); + send(&mut app, captured); + }; + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.); + send(&mut app, captured); + }; + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.request.is_some()); + let event = toggle(&app, current, "uses.arcadia".into()); + send(&mut app, event); + assert!(app.source_symbols.request.is_none()); + assert_ne!(app.source_symbols.interaction, interaction); + assert_eq!( + app.source_symbols.in_flight, + Some(request.job), + "intent does not release an occupied worker" + ); + app.source_symbols.request = Some(request.clone()); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.); + send(&mut app, captured); + }; + assert!(app.source_symbols.request.is_none()); + app.source_symbols.request = Some(request.clone()); + { + let captured = Event::HideDrawer(app.text_drawer_context()); + send(&mut app, captured); + }; + assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.text_workspace.references().stamp(), Some(current)); +} + +#[test] +fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() { + let mut app = fixture(); + let current = stamp(&app, 1); + let path = app.source_workspace.active.clone(); + app.text_workspace.begin_references(current, path.clone()); + app.text_workspace + .install_reference_files(current, [path.clone()]); + let event = toggle(&app, current, path.clone()); + send(&mut app, event); + assert!(app.text_workspace.references().collapsed(&path)); + let accepted = app.document.snapshot(); + let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit( + widget::text_editor::Edit::Insert(' '), + ))); + assert_ne!(current.snapshot, app.source_key()); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let event = toggle(&app, current, path.clone()); + send(&mut app, event); + assert!(!app.text_workspace.references().collapsed(&path)); + assert_eq!(app.source_workspace.active, path); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty()); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); +} + +#[test] +fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() { + let mut app = fixture(); + let old = open(&mut app); + let sequence = app.text_workspace_menu().unwrap().token; + app.reset(Project::empty(), None); + assert!(app.text_workspace.menu().is_none()); + app.page = Page::Text; + let fresh = open(&mut app); + assert_ne!(old, fresh); + assert!(app.text_workspace_menu().unwrap().token > sequence); + invoke(&mut app, old, Command::Find); + assert!(!app.source_navigation.open); + assert_eq!(app.text_workspace.menu().unwrap().token(), fresh); +} + +#[test] +fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() { + for hide in [false, true] { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + let old_visit = app.text_workspace.drawer_visit(); + let publication = app.text_workspace.diagnostic_publication(); + let targets = [location(&app, 0, 1), location(&app, 1, 2)]; + let old = targets + .iter() + .cloned() + .map(|target| app.text_diagnostic_message(target).unwrap()) + .collect::>(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let history = ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty(), + ); + let event = if hide { + Event::HideDrawer(app.text_drawer_context()) + } else { + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References) + }; + send(&mut app, event); + for reopened in [false, true] { + if reopened { + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + } else { + assert!(app.text_diagnostic_message(targets[0].clone()).is_none()); + } + assert_ne!(app.text_workspace.drawer_visit(), old_visit); + assert_eq!( + app.text_workspace.diagnostic_publication(), + publication, + "same list and source isolate the visit guard from publication freshness" + ); + let context = app.text_command_context(); + let drawer = app.text_workspace.drawer(); + let visit = app.text_workspace.drawer_visit(); + for message in old.iter().cloned() { + let _ = app.update(message); + assert_eq!( + app.text_command_context(), + context, + "stale diagnostic must fail before interaction invalidation" + ); + assert_eq!(app.text_workspace.drawer(), drawer); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!( + ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty() + ), + history + ); + } + } + for target in targets { + let fresh = app.text_diagnostic_message(target).unwrap(); + let _ = app.update(fresh); + assert!( + app.source_symbols.reveal.is_some(), + "fresh primary and related targets remain usable" + ); + app.source_interaction(); + } + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() { + for replacement in 0..3 { + let mut app = fixture(); + let current = stamp(&app, 1); + app.text_workspace + .begin_references(current, "a.arcadia".into()); + app.text_workspace + .install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]); + let old = toggle(&app, current, "a.arcadia".into()); + let old_visit = app.text_workspace.drawer_visit(); + match replacement { + 0 => { + { + let captured = Event::HideDrawer(app.text_drawer_context()); + send(&mut app, captured); + }; + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References); + send(&mut app, captured); + }; + } + 1 => { + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References); + send(&mut app, captured); + }; + } + _ => { + let event = toggle(&app, current, "b.arcadia".into()); + send(&mut app, event); + } + } + let visit = app.text_workspace.drawer_visit(); + assert_ne!(visit, old_visit); + send( + &mut app, + Event::ReferenceScrolled { + stamp: current, + visit, + y: 84., + }, + ); + let scroll_id = app.text_workspace.reference_scroll_id(); + let request = nav::Request { + job: Id::new_v4(), + interaction: app.source_symbols.interaction, + raw_interaction: 0, + stamp: current, + path: app.source_workspace.active.clone(), + offset: 0, + cursor: app.source_editor.cursor(), + action: nav::query::Action::References, + }; + app.source_symbols.request = Some(request.clone()); + app.source_symbols.in_flight = Some(request.job); + let groups = + ["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path)); + let context = app.text_command_context(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + for _ in 0..2 { + send(&mut app, old.clone()); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert_eq!(app.text_workspace.references().scroll_y(), 84.); + assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id); + assert_eq!(app.text_workspace.references().stamp(), Some(current)); + assert_eq!( + ["a.arcadia", "b.arcadia"] + .map(|path| app.text_workspace.references().collapsed(path)), + groups, + ); + assert_eq!(app.text_command_context(), context); + assert_eq!(app.source_symbols.request.as_ref(), Some(&request)); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + } + let fresh = toggle(&app, current, "a.arcadia".into()); + send(&mut app, fresh); + assert_eq!( + app.text_workspace.references().collapsed("a.arcadia"), + !groups[0] + ); + assert_ne!(app.text_workspace.drawer_visit(), visit); + assert_ne!(app.source_symbols.interaction, context.interaction); + assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn annotation_snapshot_requires_exact_current_bytes_and_publication() { + let mut app = fixture(); + let original_model = app.document.snapshot(); + let original_source = app.source_snapshot.clone(); + let publication = app.source_annotation_snapshot().unwrap().publication; + let initial = app.source_annotation_snapshot().unwrap(); + assert!(Arc::ptr_eq(&initial.source, &original_source)); + assert_eq!(initial.path, app.source_workspace.active); + assert!(std::ptr::eq( + initial.diagnostics, + app.source_diagnostics.as_slice() + )); + + // Republish identical diagnostics: old cached geometry must get a new identity. + app.publish_source_diagnostics(app.source_diagnostics.clone()); + assert_ne!( + app.source_annotation_snapshot().unwrap().publication, + publication + ); + + app.source_workspace + .edit("// newer draft\n".into(), app.source_editor.cursor()); + assert!( + app.source_annotation_snapshot().is_none(), + "old publication after a real draft edit" + ); + // A current source key alone must not bind new ranges to the old displayed text. + app.publish_source_diagnostics(Vec::new()); + assert!( + app.source_annotation_snapshot().is_none(), + "displayed snapshot still contains old bytes" + ); + app.show_source_file(false); + let current = app.source_annotation_snapshot().unwrap(); + assert_eq!(current.source.as_ref(), "// newer draft\n"); + assert!(current.diagnostics.is_empty()); + assert!(Arc::ptr_eq(&original_model, &app.document.snapshot())); + app.text_workspace.reset(); + assert!(app.source_annotation_snapshot().is_none()); +} + +#[test] +fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() { + let mut project = Project::empty(); + let element = project + .add_element( + oa_core::Kind::Activity, + oa_core::Layer::Oa, + "Assess incident", + None, + ) + .unwrap(); + let mut app = App::with_project(project); + app.page = Page::Text; + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let model = app.document.snapshot(); + let entry = app.source_workspace.entry.clone(); + let fragment = app.document.compiled().index.declarations[&element] + .path + .clone(); + assert_ne!(entry, fragment); + for path in [&entry, &fragment, &entry] { + let _ = app.update(Message::SourceFile(path.clone())); + let current = app.source_annotation_snapshot().unwrap(); + assert_eq!(current.path, path); + assert_eq!( + current.source.as_ref(), + app.source_workspace.files[path].text + ); + assert!(Arc::ptr_eq(¤t.source, &app.source_snapshot)); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + } +} + +#[test] +fn drawer_document_session_and_visit_guards_are_independently_required() { + for component in 0..3 { + for action in 0..3 { + let mut app = fixture(); + app.text_workspace.show_drawer(Some(DrawerTab::Status)); + let mut captured = app.text_drawer_context(); + match component { + 0 => captured.document = Id::new_v4(), + 1 => captured.session = Id::new_v4(), + _ => captured.visit = Id::new_v4(), + } + app.queue_source_reveal(app.source_editor.cursor(), false); + let reveal = app.source_reveal_ticket(); + assert!(reveal.is_some()); + let interaction = app.source_symbols.interaction; + let cursor = app.source_editor.cursor(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let current = app.text_drawer_context(); + let height = app.text_workspace.drawer_height(); + let event = match action { + 0 => Event::ShowDrawer(captured, DrawerTab::Problems), + 1 => Event::HideDrawer(captured), + _ => Event::ResizeDrawer(captured, height + 60.), + }; + send(&mut app, event); + assert_eq!(app.text_drawer_context(), current); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status)); + assert_eq!(app.text_workspace.drawer_height(), height); + assert_eq!(app.source_reveal_ticket(), reveal); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + +#[cfg(debug_assertions)] +#[test] +fn actual_chooser_blocks_diagnostic_actions_and_mounted_hover_until_closed() { + use oa_core::{language::Diagnostic, source_project::SourceDiagnostic, validation::Severity}; + for republish in [false, true] { + let mut app = fixture(); + assert!(app.source_workspace.text().starts_with("arcadia")); + app.publish_source_diagnostics(vec![SourceDiagnostic { + path: app.source_workspace.active.clone(), + diagnostic: Diagnostic { + severity: Severity::Warning, + code: "union".into(), + message: "Union diagnostic details".into(), + object_id: None, + field: None, + line: 1, + column: 1, + span: Span { start: 0, end: 7 }, + }, + related: Vec::new(), + }]); + let show = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, show); + let old_target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap(); + let mut mounted = crate::source_annotations::AppMounted::new( + app.source_annotation_snapshot().unwrap(), + app.source_navigation_available(), + app.source_symbols.raw_interaction.clone(), + ); + mounted.assert_hover(true); + let open = app.diagram_usage_message(app.project().id); + let _ = app.update(open); + let token = app + .diagram_usages + .as_ref() + .expect("actual chooser opened") + .token; + assert!(!app.source_navigation_available()); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let interaction = app.source_symbols.interaction; + let reveal = app.source_reveal_ticket(); + if republish { + app.publish_source_diagnostics(app.source_diagnostics.clone()); + } + mounted.refresh( + app.source_annotation_snapshot().unwrap(), + app.source_navigation_available(), + ); + mounted.assert_hover(false); + let _ = app.update(old_target.clone()); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.source_reveal_ticket(), reveal); + assert_eq!(app.diagram_usages.as_ref().unwrap().token, token); + assert_eq!(app.diagram_lookup_in_flight, Some(token)); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + let _ = app.update(Message::DiagramUsage(crate::diagram_usage::Event::Close( + token, + ))); + assert!(app.source_navigation_available()); + mounted.refresh( + app.source_annotation_snapshot().unwrap(), + app.source_navigation_available(), + ); + mounted.assert_hover(true); + if republish { + let interaction = app.source_symbols.interaction; + let reveal = app.source_reveal_ticket(); + let _ = app.update(old_target); + assert_eq!( + app.source_symbols.interaction, interaction, + "old publication stays stale after modal closes" + ); + assert_eq!(app.source_reveal_ticket(), reveal); + } + let target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap(); + let _ = app.update(target); + assert!( + app.source_reveal_ticket().is_some(), + "fresh admitted location still navigates" + ); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} diff --git a/crates/oa-desktop/src/diagnostic_input.rs b/crates/oa-desktop/src/diagnostic_input.rs new file mode 100644 index 00000000..d26b36ec --- /dev/null +++ b/crates/oa-desktop/src/diagnostic_input.rs @@ -0,0 +1,301 @@ +//! Publication-and-visit-bound input for a mounted diagnostic list. +//! +//! Keep this wrapper at the same widget-tree position, inside the list's outer +//! scroller. Pass `None` with an empty child while hidden/removed; unmounting the +//! wrapper loses its ability to consume an outstanding release. Every change to +//! row identity/order/action must have a fresh publication Id. Pass the pair +//! (publication generation, drawer visit): a new visit resets armed controls even +//! if Hide and Show coalesce without an intermediate hidden Tree. The child tree +//! resets when either component changes, clearing button presses and row focus; +//! the outer scroller and editor trees are not reset. +//! +//! Capturing stops Iced event bubbling and later consumers that respect capture. +//! It cannot undo an earlier sibling's event handling. This is a row-list guard, +//! not a root event filter or a guard for actions in child overlays. Diagnostic +//! messages still need publication/snapshot and drawer-visit validation at dispatch. +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget}; +use iced::{Element, Event, Length, Rectangle, Size, Vector, mouse, touch}; +use oa_core::Id; + +pub struct Guard<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + identity: Option<(Id, Id)>, + content: Element<'a, Message, Theme, Renderer>, +} + +impl<'a, Message, Theme, Renderer> Guard<'a, Message, Theme, Renderer> { + pub fn new( + identity: Option<(Id, Id)>, + content: impl Into>, + ) -> 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 { + 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 { + 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, + swallowed: Vec, +} +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 Widget + for Guard<'_, Message, Theme, Renderer> +{ + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State { + identity: self.identity, + pressed: None, + swallowed: Vec::new(), + }) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&self, tree: &mut widget::Tree) { + let state = tree.state.downcast_mut::(); + 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 { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + 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::(); + 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> { + 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> for Element<'a, Message, Theme, Renderer> +{ + fn from(guard: Guard<'a, Message, Theme, Renderer>) -> Self { + Element::new(guard) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/diagnostic_input/tests.rs b/crates/oa-desktop/src/diagnostic_input/tests.rs new file mode 100644 index 00000000..6f390581 --- /dev/null +++ b/crates/oa-desktop/src/diagnostic_input/tests.rs @@ -0,0 +1,626 @@ +use super::*; +use iced::advanced::widget::operation::{self, Focusable, Scrollable}; +use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input}; +use iced::{Point, Theme, event, keyboard}; + +type View = Element<'static, Message, Theme, ()>; +const BOUNDS: Rectangle = Rectangle { + x: 0., + y: 0., + width: 400., + height: 300., +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Message { + Primary(u8), + Related(u8), + BackgroundButton, + BackgroundRelease, + Submit(u8), + Edit, +} + +fn rows(version: u8) -> View { + column![ + button(Space::new()) + .width(200.) + .height(40.) + .padding(0) + .on_press(Message::Primary(version)), + button(Space::new()) + .width(200.) + .height(40.) + .padding(0) + .on_press(Message::Related(version)), + ] + .into() +} +fn guarded(generation: Option, 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, event::Status) { + self.event_with_capture(event, point, false) + } + fn event_with_capture( + &mut self, + event: Event, + point: Point, + captured: bool, + ) -> (Vec, 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); + 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) -> 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)); + } +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 5d802195..282766b3 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -8,6 +8,7 @@ mod connection_tool; mod context_menu; mod data_values; mod data_view; +mod diagnostic_input; mod diagram; mod diagram_usage; mod document; @@ -26,6 +27,7 @@ mod port_workflow; mod recovery; mod route_edit; mod semantic_review; +mod source_annotations; mod source_navigation; mod source_rename; mod source_tree; diff --git a/crates/oa-desktop/src/source_annotations.rs b/crates/oa-desktop/src/source_annotations.rs new file mode 100644 index 00000000..b5a3ff6d --- /dev/null +++ b/crates/oa-desktop/src/source_annotations.rs @@ -0,0 +1,312 @@ +//! Read-only source-range admission and projection onto the editor's real layout. +//! Byte ranges retain their diagnostic meaning; visual bands expand to clusters. +mod widget; +use iced::{Point, Rectangle, Size, Vector, advanced::graphics::text::cosmic_text}; +use oa_core::source_project::SourceDiagnostic; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; +use unicode_segmentation::UnicodeSegmentation; +pub(crate) use widget::{Decorations, Snapshot}; + +#[derive(Debug)] +struct Part { + diagnostic: usize, + bytes: Range, +} + +/// 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, + lines: Vec>, + parts: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Band { + pub diagnostic: usize, + pub bounds: Rectangle, + pub underline: Rectangle, +} + +impl Index { + pub fn new(source: Arc, 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> = 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 { + 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 { + 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 { + // Multiple glyphs can share a source cluster. Their iterator order follows + // paragraph layout, so its last entry need not be the logical trailing edge. + let rtl = cluster.level.is_rtl(); + let mut edge = None; + for glyph in glyphs + .iter() + .filter(|glyph| glyph.start == cluster.start && glyph.end == cluster.end) + { + if glyph.level.is_rtl() != rtl { + return None; + } + let next = if rtl { + glyph.x.min(glyph.x + glyph.w) + } else { + glyph.x.max(glyph.x + glyph.w) + }; + edge = Some(edge.map_or(next, |value: f32| { + if rtl { + value.min(next) + } else { + value.max(next) + } + })); + } + edge +} + +#[cfg(test)] +mod layout_tests; +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod anchor_tests; +#[cfg(test)] +mod performance_tests; + +#[cfg(all(test, debug_assertions))] +pub(crate) use widget::tests::AppMounted; diff --git a/crates/oa-desktop/src/source_annotations/anchor_tests.rs b/crates/oa-desktop/src/source_annotations/anchor_tests.rs new file mode 100644 index 00000000..5063ff7e --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/anchor_tests.rs @@ -0,0 +1,260 @@ +//! Manual actual-font witnesses; missing supported glyphs fail fixture admission. +use super::*; +use iced::advanced::{ + graphics::text::Editor, + text::{ + Editor as _, Highlighter as _, LineHeight, Wrapping, + highlighter::{Format, PlainText}, + }, +}; +use iced::{Font, Pixels}; + +fn shaped(source: &str, width: f32) -> Editor { + let mut editor = Editor::with_text(source); + let mut highlighter = PlainText::new(&()); + editor.update( + Size::new(width, 1200.), + Font::MONOSPACE, + Pixels(16.), + LineHeight::Absolute(Pixels(24.)), + Wrapping::WordOrGlyph, + &mut highlighter, + ); + editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default()); + editor +} + +fn trailing(cluster: &[&cosmic_text::LayoutGlyph]) -> f32 { + let rtl = cluster[0].level.is_rtl(); + assert!(cluster.iter().all(|g| g.level.is_rtl() == rtl)); + if rtl { + cluster + .iter() + .map(|g| g.x.min(g.x + g.w)) + .fold(f32::INFINITY, f32::min) + } else { + cluster + .iter() + .map(|g| g.x.max(g.x + g.w)) + .fold(f32::NEG_INFINITY, f32::max) + } +} + +#[test] +#[ignore = "manual supported-font cluster and bidi witnesses"] +fn final_multi_glyph_cluster_anchor_uses_logical_trailing_advance_edge() { + // Marks without precomposed forms exercise real multi-glyph shaping. Test + // both paragraph directions and both final-cluster directions. No font is + // assumed to support a given specimen; glyph_id=0 cannot admit a witness. + let sources = [ + "abc q\u{301}\u{323}", + "אבג q\u{301}\u{323}", + "abc א\u{5b0}\u{5bc}", + "אבג א\u{5b0}\u{5bc}", + "abc ب\u{64e}\u{651}", + "אבג ب\u{64e}\u{651}", + "abc x\u{20dd}", + "אבג x\u{20dd}", + ]; + let mut admitted = 0; + let mut opposite_direction = 0; + for source in sources { + let editor = shaped(source, 700.); + let runs: Vec<_> = editor.buffer().layout_runs().collect(); + assert_eq!(runs.len(), 1, "wide specimen unexpectedly wrapped"); + let run = &runs[0]; + let Some(start) = run + .glyphs + .iter() + .filter(|g| g.end == source.len()) + .map(|g| g.start) + .max() + else { + continue; + }; + let cluster: Vec<_> = run + .glyphs + .iter() + .filter(|g| g.start == start && g.end == source.len()) + .collect(); + if cluster.len() < 2 || cluster.iter().any(|g| g.glyph_id == 0) { + continue; + } + admitted += 1; + opposite_direction += usize::from(cluster[0].level.is_rtl() != run.rtl); + let expected = trailing(&cluster); + let actual = anchor(editor.buffer(), run, source.len()); + eprintln!( + "EOF witness {source:?}: paragraph_rtl={} cluster={cluster:?} expected={expected} actual={actual:?}", + run.rtl + ); + assert!( + actual.is_some_and(|x| (x - expected).abs() < 0.01), + "logical EOF must follow the whole cluster, including shared-range glyphs; source={source:?}" + ); + } + assert!( + admitted > 0, + "fixture admission: current fonts produced no supported multi-glyph final cluster" + ); + assert!( + opposite_direction > 0, + "fixture admission: no supported final cluster opposed its paragraph direction" + ); +} + +#[test] +#[ignore = "manual supported-font cluster and bidi witnesses"] +fn mixed_direction_omitted_separator_has_one_logical_predecessor_anchor() { + let sources = [ + "one אבג דהו two זחט three יכל four מנס five עפצ six קרש seven", + "אבג one two דהו three four זחט five six יכל seven eight מנס nine", + ]; + let mut admitted = 0; + let mut rtl_predecessors = 0; + let mut failures = Vec::new(); + let mut old_mismatches = 0; + let mut old_inside_mismatches = 0; + for source in sources { + for width in [72., 96., 120., 144., 180.] { + let editor = shaped(source, width); + let runs: Vec<_> = editor.buffer().layout_runs().collect(); + assert!(runs.len() > 1); + assert_eq!( + runs.len(), + editor.buffer().lines[0].layout_opt().unwrap().len(), + "negative control requires the complete unscrolled paragraph" + ); + assert!( + runs.iter() + .any(|r| r.glyphs.iter().any(|g| g.level.is_rtl())) + ); + assert!( + runs.iter() + .any(|r| r.glyphs.iter().any(|g| !g.level.is_rtl())) + ); + for (byte, character) in source.char_indices().filter(|(_, c)| *c == ' ') { + // Require a genuinely omitted separator, not a rendered space. + if runs + .iter() + .any(|r| r.glyphs.iter().any(|g| g.start <= byte && byte < g.end)) + { + continue; + } + let Some(end) = runs + .iter() + .flat_map(|r| r.glyphs) + .filter(|g| g.end <= byte) + .map(|g| g.end) + .max() + else { + continue; + }; + let Some(next) = runs + .iter() + .flat_map(|r| r.glyphs) + .filter(|g| g.start >= byte + character.len_utf8()) + .map(|g| g.start) + .min() + else { + continue; + }; + if !source[end..next].chars().all(char::is_whitespace) { + continue; + } + let previous: Vec<_> = runs + .iter() + .enumerate() + .filter(|(_, r)| r.glyphs.iter().any(|g| g.end == end)) + .collect(); + // A cluster broken across runs is a separate boundary problem; + // it is not admitted as this omitted-separator witness. + if previous.len() != 1 { + continue; + } + let (owner, run) = previous[0]; + let start = run + .glyphs + .iter() + .filter(|g| g.end == end) + .map(|g| g.start) + .max() + .unwrap(); + let cluster: Vec<_> = run + .glyphs + .iter() + .filter(|g| g.start == start && g.end == end) + .collect(); + if cluster.iter().any(|g| g.glyph_id == 0) { + continue; + } + admitted += 1; + rtl_predecessors += usize::from(cluster[0].level.is_rtl()); + let expected = trailing(&cluster); + let actual: Vec<_> = runs + .iter() + .enumerate() + .filter_map(|(i, r)| anchor(editor.buffer(), r, byte).map(|x| (i, x))) + .collect(); + let matches_expected = |positions: &[(usize, f32)]| { + positions.len() == 1 + && positions[0].0 == owner + && (positions[0].1 - expected).abs() < 0.01 + }; + // Exact rejected fallback, for already-proven omitted bytes: + // per-run maximum end and the next visual run's minimum start. + let old: Vec<_> = runs + .iter() + .enumerate() + .filter_map(|(i, r)| { + let last = r.glyphs.iter().max_by_key(|g| g.end)?; + let next_start = runs + .get(i + 1) + .and_then(|next| next.glyphs.iter().map(|g| g.start).min()); + (byte >= last.end + && (next_start.is_some_and(|start| byte < start) + || (next_start.is_none() && byte == source.len()))) + .then_some(( + i, + if last.level.is_rtl() { + last.x + } else { + last.x + last.w + }, + )) + }) + .collect(); + let inside_clip = expected >= 0. && expected <= width; + if !matches_expected(&old) { + old_mismatches += 1; + old_inside_mismatches += usize::from(inside_clip); + } + eprintln!( + "gap witness width={width} source={source:?} byte={byte} owner={owner} expected={expected} inside_horizontal_clip={inside_clip} actual={actual:?} rejected_old={old:?} cluster={cluster:?}" + ); + if !matches_expected(&actual) { + failures.push(format!("width={width} source={source:?} byte={byte} expected=({owner},{expected}) inside={inside_clip} actual={actual:?}")); + } + } + } + } + assert!( + admitted > 0, + "fixture admission: no omitted mixed-direction separator found" + ); + assert!( + rtl_predecessors > 0, + "fixture admission: no omitted separator after a supported RTL cluster" + ); + eprintln!( + "gap summary admitted={admitted} rejected_old_mismatches={old_mismatches} rejected_old_inside_horizontal_clip={old_inside_mismatches}" + ); + assert!( + old_mismatches > 0, + "actual-layout negative control must discriminate the old fallback" + ); + assert!( + failures.is_empty(), + "mixed-bidi logical anchor failures: {failures:#?}" + ); +} diff --git a/crates/oa-desktop/src/source_annotations/layout_tests.rs b/crates/oa-desktop/src/source_annotations/layout_tests.rs new file mode 100644 index 00000000..1e9dbc41 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/layout_tests.rs @@ -0,0 +1,321 @@ +//! Real shaped-layout projection checks. No window/GPU or hover authority claim. +use super::*; +use iced::advanced::{ + graphics::text::Editor, + text::{ + Editor as _, Highlighter as _, LineHeight, Wrapping, + highlighter::{Format, PlainText}, + }, +}; +use iced::{Font, Pixels}; +use oa_core::{ + language::{Diagnostic, Span}, + validation::Severity, +}; + +const ORIGIN: Point = Point::new(37., 53.); +fn diagnostic(start: usize, end: usize) -> SourceDiagnostic { + SourceDiagnostic { + path: "a".into(), + diagnostic: Diagnostic { + severity: Severity::Error, + code: "layout".into(), + message: "fixture".into(), + object_id: None, + field: None, + line: 999, + column: 999, + span: Span { start, end }, + }, + related: Vec::new(), + } +} +fn editor(source: &str, width: f32, height: f32) -> Editor { + let mut editor = Editor::with_text(source); + let mut highlighter = PlainText::new(&()); + editor.update( + Size::new(width, height), + Font::MONOSPACE, + Pixels(16.), + LineHeight::Absolute(Pixels(24.)), + Wrapping::WordOrGlyph, + &mut highlighter, + ); + editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default()); + editor +} +fn index(source: &str, ranges: &[Range]) -> Index { + Index::new( + Arc::from(source), + "a", + &ranges + .iter() + .map(|r| diagnostic(r.start, r.end)) + .collect::>(), + ) +} +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" + ); +} diff --git a/crates/oa-desktop/src/source_annotations/performance_tests.rs b/crates/oa-desktop/src/source_annotations/performance_tests.rs new file mode 100644 index 00000000..6fcd990a --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/performance_tests.rs @@ -0,0 +1,216 @@ +//! Manual actual-layout timing distributions; no fixed machine threshold. +use super::*; +use iced::advanced::{ + graphics::text::Editor, + text::{ + Editor as _, Highlighter as _, LineHeight, Wrapping, + highlighter::{Format, PlainText}, + }, +}; +use iced::{Font, Pixels}; +use oa_core::{ + language::{Diagnostic, Span}, + validation::Severity, +}; +use std::{hint::black_box, time::Instant}; + +fn diagnostic(start: usize, end: usize) -> SourceDiagnostic { + SourceDiagnostic { + path: "a".into(), + diagnostic: Diagnostic { + severity: Severity::Warning, + code: "perf".into(), + message: "prepared geometry specimen".into(), + object_id: None, + field: None, + line: 0, + column: 0, + span: Span { start, end }, + }, + related: Vec::new(), + } +} +fn shaped(source: &str) -> Editor { + let mut e = Editor::with_text(source); + settle_layout(&mut e); + e +} + +// Stock TextEditor calls Editor::update during the next layout after input. +// Scroll initially changes pixel offset only; update normalizes logical scroll +// and shapes newly visible paragraphs before draw/highlight consumes them. +fn settle_layout(e: &mut Editor) { + let mut h = PlainText::new(&()); + e.update( + Size::new(640., 240.), + Font::MONOSPACE, + Pixels(16.), + LineHeight::Absolute(Pixels(24.)), + Wrapping::WordOrGlyph, + &mut h, + ); + e.highlight(Font::MONOSPACE, &mut h, |_| Format::default()); +} +fn distribution(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, 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::() + }); + 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 = 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 = 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" + ); +} diff --git a/crates/oa-desktop/src/source_annotations/tests.rs b/crates/oa-desktop/src/source_annotations/tests.rs new file mode 100644 index 00000000..d8624d6e --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/tests.rs @@ -0,0 +1,83 @@ +use super::*; +use oa_core::{ + language::{Diagnostic, Span}, + validation::Severity, +}; + +fn diagnostic(path: &str, start: usize, end: usize) -> SourceDiagnostic { + SourceDiagnostic { + path: path.into(), + diagnostic: Diagnostic { + severity: Severity::Error, + code: "test".into(), + message: "synthetic admission fixture".into(), + object_id: None, + field: None, + line: 999, + column: 999, + span: Span { start, end }, + }, + related: Vec::new(), + } +} + +#[test] +fn exact_path_and_utf8_range_admission_keep_valid_neighbor() { + let index = Index::new( + Arc::from("雨 x"), + "a.arcadia", + &[ + diagnostic("b.arcadia", 0, 3), + diagnostic("a.arcadia", 1, 3), + diagnostic("a.arcadia", 4, 2), + diagnostic("a.arcadia", 0, 99), + diagnostic("a.arcadia", 4, 5), + ], + ); + assert_eq!(index.parts.len(), 1); + let parts = &index.parts[&0]; + assert_eq!(parts.len(), 1); + assert_eq!((parts[0].diagnostic, parts[0].bytes.clone()), (4, 4..5)); +} + +#[test] +fn mixed_endings_use_actual_paragraphs_and_preserve_eof() { + let index = Index::new( + Arc::from("a\r\n雨\rb\n\rc\n"), + "a", + &[ + diagnostic("a", 3, 6), + diagnostic("a", 7, 8), + diagnostic("a", 12, 12), + ], + ); + assert_eq!(index.lines, vec![0..1, 3..6, 7..8, 10..11, 12..12]); + assert_eq!(index.parts[&1][0].bytes, 0..3); + assert_eq!(index.parts[&2][0].bytes, 0..1); + assert_eq!(index.parts[&4][0].bytes, 0..0); +} + +#[test] +fn half_open_next_line_excluded_separator_only_and_empty_line_anchored() { + let index = Index::new( + Arc::from("ab\r\n\r\ncd"), + "a", + &[ + diagnostic("a", 0, 4), + diagnostic("a", 2, 4), + diagnostic("a", 4, 6), + ], + ); + assert_eq!(index.parts.len(), 2); + assert_eq!(index.parts[&0][0].bytes, 0..2); + assert_eq!(index.parts[&0][1].bytes, 2..2); + assert_eq!(index.parts[&1][0].bytes, 0..0); + assert!(!index.parts.contains_key(&2)); +} + +#[test] +fn empty_source_has_real_zero_width_marker() { + let index = Index::new(Arc::from(""), "a", &[diagnostic("a", 0, 0)]); + assert_eq!(index.lines, vec![0..0]); + assert_eq!(index.parts[&0][0].bytes, 0..0); +} diff --git a/crates/oa-desktop/src/source_annotations/widget.rs b/crates/oa-desktop/src/source_annotations/widget.rs new file mode 100644 index 00000000..7f2c9edd --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/widget.rs @@ -0,0 +1,425 @@ +//! Permanent passive decoration around the existing editor. Draw uses current +//! geometry; only the source index is cached, never pointer hitboxes or messages. +//! Current-frame tooltip paragraphs stay owned until deferred renderer preparation. +use super::Index; +use iced::advanced::text::Paragraph as _; +use iced::{ + Element, Event, Font, Length, Padding, Point, Rectangle, Size, Theme, Vector, + advanced::{ + Clipboard, Layout, Shell, Widget, graphics, layout, overlay, renderer, text, widget, + }, + alignment, mouse, + widget::text_editor, +}; +use oa_core::{Id, source_project::SourceDiagnostic, validation::Severity}; +use std::{cell::RefCell, sync::Arc}; + +pub(crate) struct Decorations<'a, Message, R: text::Renderer> { + child: Element<'a, Message, Theme, R>, + content: &'a text_editor::Content, + snapshot: Option>, + 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, + pub path: &'a str, + pub diagnostics: &'a [SourceDiagnostic], +} + +impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> { + pub fn new( + child: impl Into>, + content: &'a text_editor::Content, + snapshot: Option>, + padding: Padding, + hover_enabled: bool, + ) -> Self { + Self { + child: child.into(), + content, + snapshot, + padding, + hover_enabled, + } + } +} + +struct Cached { + publication: Id, + path: String, + source: Arc, + index: Index, +} +#[derive(Default)] +struct State { + index: RefCell>, + details: RefCell>, +} + +// 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 Widget 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::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + fn children(&self) -> Vec { + 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 { + self.child.as_widget().size() + } + fn size_hint(&self) -> Size { + 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::(); + // 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> { + self.child.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message: 'a, R> From> 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>( + renderer: &mut R, + theme: &Theme, + diagnostics: &[SourceDiagnostic], + hits: &[usize], + point: Point, + clip: Rectangle, +) -> Option { + const PAD: f32 = 8.; + if clip.width < 100. || clip.height < 80. { + return None; + } + let width = 520_f32.min(clip.width); + let mut details = Vec::new(); + for ordinal in hits.iter().take(4) { + let issue = &diagnostics[*ordinal]; + let d = &issue.diagnostic; + let severity = match d.severity { + Severity::Error => "Error", + Severity::Warning => "Warning", + }; + let raw = format!( + "{severity} [{}] · {}:{}\n{}", + d.code, d.line, d.column, d.message + ); + let mut value: String = raw.chars().take(1000).collect(); + if value.len() < raw.len() { + value.push('…'); + } + if !issue.related.is_empty() { + value.push_str(&format!( + "\n{} related location(s) in Problems", + issue.related.len() + )); + } + details.push(value); + } + let content = details.join("\n\n"); + let paragraph = graphics::text::Paragraph::with_text(text::Text { + content: content.as_str(), + bounds: Size::new(width - PAD * 2., f32::INFINITY), + size: 12.into(), + line_height: text::LineHeight::Relative(1.3), + font: renderer.default_font(), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::WordOrGlyph, + }); + let remaining = hits.len().saturating_sub(4); + let footer = if remaining > 0 { + format!( + "4 of {} shown · {remaining} more\nFull text: Actions → Source problems", + hits.len() + ) + } else { + "Full text and locations: Actions → Source problems".into() + }; + let footer_paragraph = graphics::text::Paragraph::with_text(text::Text { + content: footer.as_str(), + bounds: Size::new(width - PAD * 2., f32::INFINITY), + size: 11.into(), + line_height: text::LineHeight::Relative(1.2), + font: renderer.default_font(), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::WordOrGlyph, + }); + let footer_height = footer_paragraph.min_bounds().height; + let available_body = clip.height - PAD * 2. - footer_height - 4.; + if available_body < 16. { + return None; + } + let max_body_height = + (clip.height * 0.6 - PAD * 2. - footer_height - 4.).clamp(16., available_body); + let body_height = paragraph.min_bounds().height.min(max_body_height); + let height = PAD * 2. + body_height + 4. + footer_height; + let x = point.x.min(clip.x + clip.width - width).max(clip.x); + let y = if point.y + 18. + height <= clip.y + clip.height { + point.y + 18. + } else { + (point.y - 18. - height).max(clip.y) + }; + let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height)); + let palette = theme.extended_palette(); + renderer.fill_quad( + renderer::Quad { + bounds, + border: iced::Border { + color: palette.background.strong.color, + width: 1., + radius: 4.into(), + }, + ..Default::default() + }, + palette.background.weak.color, + ); + let body = Rectangle::new( + Point::new(x + PAD, y + PAD), + Size::new(width - PAD * 2., body_height), + ); + renderer.fill_paragraph( + ¶graph, + body.position(), + palette.background.weak.text, + body, + ); + let footer_bounds = Rectangle::new( + Point::new(x + PAD, body.y + body.height + 4.), + Size::new(width - PAD * 2., footer_height), + ); + renderer.fill_paragraph( + &footer_paragraph, + footer_bounds.position(), + palette.background.weak.text, + footer_bounds, + ); + Some(DetailsParagraphs { + _body: paragraph, + _footer: footer_paragraph, + }) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) mod tests; diff --git a/crates/oa-desktop/src/source_annotations/widget/tests.rs b/crates/oa-desktop/src/source_annotations/widget/tests.rs new file mode 100644 index 00000000..c991a934 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/widget/tests.rs @@ -0,0 +1,1094 @@ +//! Actual Decorations + TextEditor + existing raw-input observer, one retained +//! Tree, graphics Editor/Paragraph, ID-based focus Operation and emitted editor +//! actions. The recorder observes actual fill_editor clusters, renderer layers, +//! and shaped tooltip paragraphs. No private editor-state indexing or fake +//! character-width metrics. Deferred paragraph observations keep only the same +//! Weak used by WGPU and upgrade after draw returns, never a strong test clone. +//! Double/triple click events are batched through the +//! actual widget, then their emitted actions are applied in order to Content. +//! +//! Limits: CPU layout/advance coverage, not raster ink/GPU/native/IME, real App +//! publication admission, menu controller, or the production sensor/view tree. +//! Snapshot and hover_enabled are explicit widget-boundary inputs. Word/Line +//! insertion compares a hovered path with an actual direct-typing control. +//! Footer expectations require measured height and an explicit remaining count. +#![cfg(debug_assertions)] + +use super::{Decorations, Snapshot}; +use crate::views::source_tests::FontLayout; +use iced::advanced::{ + Layout, Shell, Widget, graphics, layout, renderer, + text::{self as shaped, Paragraph as _}, + widget as core_widget, +}; +use iced::widget::text_editor::{self, Action, Content}; +use iced::{ + Background, Color, Element, Event, Font, Padding, Pixels, Point, Rectangle, Size, Theme, + Transformation, keyboard, mouse, window, +}; +use oa_core::{ + Id, + language::{Diagnostic, Span}, + source_project::SourceDiagnostic, + validation::Severity, +}; +use std::{ + collections::BTreeSet, + ops::Range, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +const ORIGIN: Point = Point::new(23., 31.); +const PAD: f32 = 16.; +const EDITOR_ID: &str = "annotation-wrapper-editor"; +const ROW: &str = "alpha beta gamma\n"; + +#[derive(Debug, Clone)] +struct Glyph { + line: usize, + bytes: Range, + bounds: Rectangle, +} + +#[derive(Debug, Clone)] +struct ParagraphPaint { + content: String, + glyphs: Vec, + clip: Rectangle, +} + +#[derive(Debug)] +struct EditorPaint { + glyphs: Vec, + clip: Rectangle, +} + +#[derive(Debug)] +struct QuadPaint { + bounds: Rectangle, + clip: Rectangle, + layer_depth: usize, + background: Background, +} + +#[derive(Debug, Default)] +struct Frame { + editors: Vec, + paragraphs: Vec, + deferred_paragraphs: Vec, + quads: Vec, +} + +impl Frame { + fn deferred_text(&self) -> Vec> { + self.deferred_paragraphs + .iter() + .map(|weak| { + weak.upgrade().map(|paragraph| { + paragraph + .buffer() + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n") + }) + }) + .collect() + } + fn editor(&self) -> &EditorPaint { + assert_eq!(self.editors.len(), 1, "one actual fill_editor per frame"); + &self.editors[0] + } + fn point(&self, line: usize, byte: usize) -> Point { + let editor = self.editor(); + let glyph = editor + .glyphs + .iter() + .find(|g| g.line == line && g.bytes.contains(&byte) && inside(g.bounds, editor.clip)) + .expect("requested fixture cluster must be genuinely visible"); + Point::new( + glyph.bounds.x + glyph.bounds.width / 2., + glyph.bounds.y + glyph.bounds.height / 2., + ) + } + fn tooltip(&self) -> String { + self.paragraphs + .iter() + .map(|p| p.content.as_str()) + .collect::>() + .join("\n") + } + fn decoration_quads(&self) -> impl Iterator { + self.quads.iter().filter(|q| q.layer_depth > 0) + } +} + +fn inside(bounds: Rectangle, clip: Rectangle) -> bool { + const EPS: f32 = 0.05; + bounds.x >= clip.x - EPS + && bounds.y >= clip.y - EPS + && bounds.x + bounds.width <= clip.x + clip.width + EPS + && bounds.y + bounds.height <= clip.y + clip.height + EPS +} + +struct Recorder { + frame: Frame, + transforms: Vec, + clips: Vec, +} + +impl Recorder { + fn new(viewport: Rectangle) -> Self { + Self { + frame: Frame::default(), + transforms: vec![Transformation::IDENTITY], + clips: vec![viewport], + } + } + 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)) + } + fn glyphs(&self, buffer: &graphics::text::cosmic_text::Buffer, origin: Point) -> Vec { + buffer + .layout_runs() + .flat_map(|run| { + run.glyphs.iter().map(move |glyph| Glyph { + line: run.line_i, + bytes: glyph.start..glyph.end, + // Actual shaped advances and line bands, not estimated ink. + bounds: Rectangle::new( + Point::new(origin.x + glyph.x, origin.y + run.line_top), + Size::new(glyph.w, run.line_height), + ) * self.transform(), + }) + }) + .collect() + } + fn paragraph(&mut self, paragraph: &graphics::text::Paragraph, origin: Point, clip: Rectangle) { + let buffer = paragraph.buffer(); + self.frame.paragraphs.push(ParagraphPaint { + content: buffer + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n"), + glyphs: self.glyphs(buffer, origin), + clip: self.clip(clip), + }); + } +} + +impl renderer::Renderer for Recorder { + fn start_layer(&mut self, bounds: Rectangle) { + self.clips.push(self.clip(bounds)); + } + fn end_layer(&mut self) { + assert!(self.clips.len() > 1); + self.clips.pop(); + } + fn start_transformation(&mut self, value: Transformation) { + self.transforms.push(self.transform() * value); + } + fn end_transformation(&mut self) { + assert!(self.transforms.len() > 1); + self.transforms.pop(); + } + fn reset(&mut self, bounds: Rectangle) { + *self = Self::new(bounds); + } + fn fill_quad(&mut self, quad: renderer::Quad, background: impl Into) { + self.frame.quads.push(QuadPaint { + bounds: quad.bounds * self.transform(), + clip: *self.clips.last().unwrap(), + layer_depth: self.clips.len() - 1, + background: background.into(), + }); + } + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} + +impl shaped::Renderer for Recorder { + type Font = Font; + type Paragraph = graphics::text::Paragraph; + type Editor = graphics::text::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::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, + origin: Point, + _: Color, + clip: Rectangle, + ) { + self.frame.deferred_paragraphs.push(paragraph.downgrade()); + self.paragraph(paragraph, origin, clip); + } + fn fill_editor(&mut self, editor: &Self::Editor, origin: Point, _: Color, clip: Rectangle) { + self.frame.editors.push(EditorPaint { + glyphs: self.glyphs(editor.buffer(), origin), + clip: self.clip(clip), + }); + } + fn fill_text(&mut self, value: shaped::Text, origin: Point, _: Color, clip: Rectangle) { + let paragraph = graphics::text::Paragraph::with_text(value.as_ref()); + self.paragraph(¶graph, origin, clip); + } +} + +fn diagnostic(path: &str, bytes: Range, code: &str, severity: Severity) -> SourceDiagnostic { + SourceDiagnostic { + path: path.into(), + diagnostic: Diagnostic { + severity, + code: code.into(), + message: format!("Detail {code} 雨"), + object_id: None, + field: None, + line: 1, + column: 1, + span: Span { + start: bytes.start, + end: bytes.end, + }, + }, + related: Vec::new(), + } +} + +struct Mounted { + content: Content, + source: Arc, + path: String, + diagnostics: Vec, + publication: Option, + hover_enabled: bool, + tree: core_widget::Tree, + clock: Arc, + width: f32, + height: f32, + viewport: Option, +} + +impl Mounted { + fn new(source: &str) -> Self { + Self { + content: Content::with_text(source), + source: Arc::from(source), + path: "a".into(), + diagnostics: vec![ + diagnostic("a", 0..5, "A", Severity::Warning), + diagnostic("a", 6..10, "B", Severity::Error), + ], + publication: Some(Id::new_v4()), + hover_enabled: true, + tree: core_widget::Tree::empty(), + clock: Arc::new(AtomicU64::new(0)), + width: 552., + height: 392., + viewport: None, + } + } + fn with_widget( + &mut self, + visit: impl FnOnce( + &mut dyn Widget, + &mut core_widget::Tree, + Layout<'_>, + &mut Recorder, + &Rectangle, + ) -> T, + ) -> T { + let snapshot = self.publication.map(|publication| Snapshot { + publication, + source: self.source.clone(), + path: &self.path, + diagnostics: &self.diagnostics, + }); + let editor = iced::widget::text_editor(&self.content) + .id(EDITOR_ID) + .on_action(|action| action) + .font(Font::MONOSPACE) + .size(14) + .padding(PAD) + .width(self.width) + .height(self.height) + .highlight_with::( + self.source.clone(), + crate::highlight::format, + ); + let decorated = Decorations::new( + editor, + &self.content, + snapshot, + Padding::new(PAD), + self.hover_enabled, + ); + let mut view: Element<'_, Action, Theme, Recorder> = + crate::source_navigation::interaction::observe(decorated.into(), self.clock.clone()); + self.tree.diff(view.as_widget()); + let bounds = Rectangle::new(ORIGIN, Size::new(self.width, self.height)); + let viewport = self.viewport.unwrap_or(bounds); + let mut recorder = Recorder::new(viewport); + let node = view + .as_widget_mut() + .layout( + &mut self.tree, + &recorder, + &layout::Limits::new(Size::ZERO, bounds.size()), + ) + .move_to(ORIGIN); + assert_eq!(Layout::new(&node).bounds(), bounds); + visit( + view.as_widget_mut(), + &mut self.tree, + Layout::new(&node), + &mut recorder, + &viewport, + ) + } + fn draw(&mut self, cursor: mouse::Cursor) -> Frame { + self.with_widget(|widget, tree, layout, recorder, viewport| { + widget.draw( + tree, + recorder, + &Theme::Light, + &renderer::Style::default(), + layout, + cursor, + viewport, + ); + assert_eq!(recorder.clips.len(), 1, "balanced renderer layers"); + assert_eq!( + recorder.transforms.len(), + 1, + "balanced renderer transformations" + ); + std::mem::take(&mut recorder.frame) + }) + } + fn event( + &mut self, + event: &Event, + cursor: mouse::Cursor, + ) -> (Vec, bool, window::RedrawRequest) { + self.with_widget(|widget, tree, layout, renderer, viewport| { + let mut messages = Vec::new(); + let (captured, redraw) = { + let mut shell = Shell::new(&mut messages); + widget.update( + tree, + event, + layout, + cursor, + renderer, + &mut iced::advanced::clipboard::Null, + &mut shell, + viewport, + ); + (shell.is_event_captured(), shell.redraw_request()) + }; + (messages, captured, redraw) + }) + } + fn inspect_focus(&mut self, set: Option) -> bool { + struct Focus { + set: Option, + found: Option, + } + impl core_widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&core_widget::Id>, + _: Rectangle, + state: &mut dyn core_widget::operation::Focusable, + ) { + if id == Some(&core_widget::Id::new(EDITOR_ID)) { + assert!(self.found.is_none(), "duplicate editor ID"); + if let Some(focus) = self.set { + if focus { + state.focus(); + } else { + state.unfocus(); + } + } + self.found = Some(state.is_focused()); + } + } + } + let mut operation = Focus { set, found: None }; + self.with_widget(|widget, tree, layout, renderer, _| { + widget.operate(tree, layout, renderer, &mut operation) + }); + operation + .found + .expect("actual editor is reachable by ID through all wrappers") + } + fn state(&mut self) -> ContentState { + let focused = self.inspect_focus(None); + ContentState { + text: self.content.text(), + selected: self.content.selection(), + cursor: self.content.cursor(), + scroll: self + .content + .with_editor(|e| (e.buffer().scroll().line, e.buffer().scroll().vertical)), + focused, + clock: self.clock.load(Ordering::SeqCst), + } + } + fn gesture(&mut self, point: Point, clicks: usize) { + let messages = self.with_widget(|widget, tree, layout, renderer, viewport| { + let mut messages = Vec::new(); + for _ in 0..clicks { + for event in [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] { + let mut shell = Shell::new(&mut messages); + widget.update( + tree, + &event, + layout, + mouse::Cursor::Available(point), + renderer, + &mut iced::advanced::clipboard::Null, + &mut shell, + viewport, + ); + } + } + messages + }); + assert_eq!(messages.len(), clicks); + assert!(matches!(messages[0], Action::Click(_))); + assert!(matches!(messages[1], Action::SelectWord)); + if clicks == 3 { + assert!(matches!(messages[2], Action::SelectLine)); + } + for message in messages { + self.content.perform(message); + } + assert!(self.inspect_focus(None)); + } + fn type_x(&mut self) { + let event = Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character("x".into()), + modified_key: keyboard::Key::Character("x".into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some("X".into()), + repeat: false, + }); + let (messages, captured, _) = self.event(&event, mouse::Cursor::Unavailable); + assert!(captured); + assert_eq!(messages.len(), 1); + assert!(matches!( + messages[0], + Action::Edit(text_editor::Edit::Insert('X')) + )); + for action in messages { + self.content.perform(action); + } + } +} + +#[derive(Debug, PartialEq)] +struct ContentState { + text: String, + selected: Option, + cursor: text_editor::Cursor, + scroll: (usize, f32), + focused: bool, + clock: u64, +} + +fn passive(mounted: &mut Mounted, point: Option) -> Frame { + let before = mounted.state(); + let (event, cursor) = match point { + Some(point) => ( + Event::Mouse(mouse::Event::CursorMoved { position: point }), + mouse::Cursor::Available(point), + ), + None => ( + Event::Mouse(mouse::Event::CursorLeft), + mouse::Cursor::Unavailable, + ), + }; + let (messages, captured, redraw) = mounted.event(&event, cursor); + assert!( + messages.is_empty(), + "hover must not synthesize editor actions" + ); + assert!( + !captured, + "hover must not consume ordinary pointer movement" + ); + assert_eq!(redraw, window::RedrawRequest::NextFrame); + let frame = mounted.draw(cursor); + assert_eq!( + mounted.state(), + before, + "hover must preserve exact content, hidden selection, focus, scroll and raw clock" + ); + frame +} + +#[test] +fn pointer_motion_between_bands_blank_and_leave_is_passive_and_always_redraws() { + let mut mounted = Mounted::new(&ROW.repeat(24)); + mounted.diagnostics = (0..24) + .flat_map(|line| { + let start = line * ROW.len(); + [ + diagnostic("a", start..start + 5, "A", Severity::Warning), + diagnostic("a", start + 6..start + 10, "B", Severity::Error), + ] + }) + .collect(); + assert!(mounted.inspect_focus(Some(true))); + mounted.draw(mouse::Cursor::Unavailable); + mounted.content.perform(Action::Scroll { lines: 2 }); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let line = frame + .editor() + .glyphs + .iter() + .find(|g| g.bytes.contains(&2) && inside(g.bounds, frame.editor().clip)) + .unwrap() + .line; + assert!(mounted.state().scroll != (0, 0.)); + let a = frame.point(line, 2); + let b = frame.point(line, 7); + let clip = frame.editor().clip; + let blank = Point::new(clip.x + clip.width - 3., a.y); + let frame_a = passive(&mut mounted, Some(a)); + assert!(frame_a.tooltip().contains("Detail A 雨")); + assert!(!frame_a.tooltip().contains("Detail B 雨")); + let frame_b = passive(&mut mounted, Some(b)); + assert!(frame_b.tooltip().contains("Detail B 雨")); + assert!(!frame_b.tooltip().contains("Detail A 雨")); + assert!(passive(&mut mounted, Some(blank)).paragraphs.is_empty()); + assert!(passive(&mut mounted, None).paragraphs.is_empty()); + // Unfocused motion also requests repaint, without depending on caret blink. + assert!(!mounted.inspect_focus(Some(false))); + assert!( + passive(&mut mounted, Some(a)) + .tooltip() + .contains("Detail A 雨") + ); +} + +#[test] +fn actual_word_and_line_selection_then_typing_survive_hover_and_publication_changes() { + for clicks in [2, 3] { + let run = |hover: bool| { + let mut mounted = Mounted::new("alpha beta gamma\r\nsecond line 雨\r\n"); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let point = frame.point(0, 2); + mounted.gesture(point, clicks); + let selected = if clicks == 2 { + "alpha" + } else { + "alpha beta gamma" + }; + assert_eq!(mounted.content.selection().as_deref(), Some(selected)); + mounted.draw(mouse::Cursor::Unavailable); + let before = mounted.state(); + if hover { + passive(&mut mounted, Some(point)); + mounted.publication = None; + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .paragraphs + .is_empty() + ); + mounted.publication = Some(Id::new_v4()); + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .tooltip() + .contains("Detail A") + ); + passive(&mut mounted, None); + assert_eq!(mounted.state(), before); + } + mounted.type_x(); + mounted.content.text() + }; + let control = run(false); + let hovered = run(true); + let expected = if clicks == 2 { + "X beta gamma\r\nsecond line 雨\r\n" + } else { + "X\r\nsecond line 雨\r\n" + }; + assert_eq!(control, expected, "actual direct-typing control"); + assert_eq!( + hovered, expected, + "hover must preserve the effective selected bytes, not only raw cursor endpoints" + ); + } +} + +#[test] +fn stationary_pointer_replacement_absence_and_path_switch_use_only_current_index() { + let mut mounted = Mounted::new(ROW); + mounted.diagnostics = vec![ + diagnostic("a", 0..5, "A", Severity::Warning), + diagnostic("b", 0..5, "OTHER_PATH", Severity::Error), + ]; + assert!(mounted.inspect_focus(Some(true))); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let alpha = frame.point(0, 2); + let beta = frame.point(0, 7); + let before = mounted.state(); + assert!( + mounted + .draw(mouse::Cursor::Available(alpha)) + .tooltip() + .contains("Detail A") + ); + // Same publication and same source Arc; only the active logical path changes. + mounted.path = "b".into(); + let other = mounted.draw(mouse::Cursor::Available(alpha)); + assert!(other.tooltip().contains("OTHER_PATH")); + assert!(!other.tooltip().contains("Detail A")); + mounted.path = "a".into(); + assert!( + mounted + .draw(mouse::Cursor::Available(alpha)) + .tooltip() + .contains("Detail A") + ); + // Same path/source Arc but a different publication moves the diagnostic. + mounted.publication = Some(Id::new_v4()); + mounted.diagnostics = vec![diagnostic("a", 6..10, "NEW_BETA", Severity::Error)]; + assert!( + mounted + .draw(mouse::Cursor::Available(alpha)) + .paragraphs + .is_empty() + ); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .tooltip() + .contains("NEW_BETA") + ); + mounted.publication = None; + let empty = mounted.draw(mouse::Cursor::Available(beta)); + assert!(empty.paragraphs.is_empty()); + assert_eq!(empty.decoration_quads().count(), 0); + mounted.publication = Some(Id::new_v4()); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .tooltip() + .contains("NEW_BETA") + ); + assert_eq!(mounted.state(), before); +} + +#[test] +fn disabled_hover_and_unavailable_cursor_hide_details_without_removing_current_marks() { + let mut mounted = Mounted::new(ROW); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let point = frame.point(0, 2); + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .tooltip() + .contains("Detail A") + ); + let before = mounted.state(); + mounted.hover_enabled = false; + let disabled = mounted.draw(mouse::Cursor::Available(point)); + assert!(disabled.paragraphs.is_empty()); + assert!(disabled.decoration_quads().count() > 0); + mounted.hover_enabled = true; + let unavailable = mounted.draw(mouse::Cursor::Unavailable); + assert!(unavailable.paragraphs.is_empty()); + assert!(unavailable.decoration_quads().count() > 0); + assert_eq!(mounted.state(), before); +} + +fn paragraph_complete(paint: &ParagraphPaint, clip: Rectangle) -> bool { + let lines: Vec<_> = paint.content.split('\n').collect(); + let wanted: BTreeSet<_> = lines + .iter() + .enumerate() + .flat_map(|(line, text)| { + text.char_indices() + .filter(|(_, c)| !c.is_whitespace()) + .map(move |(byte, _)| (line, byte)) + }) + .collect(); + let mut covered = BTreeSet::new(); + for glyph in &paint.glyphs { + let Some(text) = lines.get(glyph.line) else { + return false; + }; + let Some(cluster) = text.get(glyph.bytes.clone()) else { + return false; + }; + if cluster.chars().all(char::is_whitespace) { + continue; + } + if !inside(glyph.bounds, clip) { + return false; + } + for (byte, character) in text.char_indices() { + if !character.is_whitespace() && glyph.bytes.contains(&byte) { + covered.insert((glyph.line, byte)); + } + } + } + !wanted.is_empty() && covered == wanted +} + +#[test] +fn deferred_paragraph_renderer_rejects_the_old_local_lifetime() { + let mut renderer = Recorder::new(Rectangle::with_size(Size::new(300., 200.))); + { + // Exact lifetime mechanism of the rejected local draw_details: queue + // the real graphics Weak, then destroy its only strong owner on return. + let local = graphics::text::Paragraph::with_text(shaped::Text { + content: "Visible during draw; absent during WGPU prepare", + bounds: Size::new(280., 180.), + size: 14.into(), + line_height: shaped::LineHeight::Relative(1.3), + font: Font::MONOSPACE, + align_x: shaped::Alignment::Left, + align_y: iced::alignment::Vertical::Top, + shaping: shaped::Shaping::Advanced, + wrapping: shaped::Wrapping::WordOrGlyph, + }); + shaped::Renderer::fill_paragraph( + &mut renderer, + &local, + Point::ORIGIN, + Color::BLACK, + Rectangle::with_size(Size::new(300., 200.)), + ); + assert!(renderer.frame.deferred_text()[0].is_some()); + } + assert!( + !renderer.frame.paragraphs[0].glyphs.is_empty(), + "the old eager recorder sees real shaped text and would miss this defect" + ); + assert_eq!( + renderer.frame.deferred_text(), + vec![None], + "WGPU cannot upgrade the queued weak paragraph after the local owner drops" + ); +} + +#[test] +fn deferred_tooltip_text_survives_draw_and_only_current_authority_is_retained() { + fn live(frame: &Frame, body: &str) { + let deferred = frame.deferred_text(); + assert_eq!(deferred.len(), 2, "body and footer are queued separately"); + assert!( + deferred.iter().all(Option::is_some), + "both real weak paragraphs must upgrade AFTER widget draw returns" + ); + assert!(deferred[0].as_ref().unwrap().contains(body)); + assert!( + deferred[1] + .as_ref() + .unwrap() + .contains("Actions → Source problems") + ); + let immediate: Vec<_> = frame + .paragraphs + .iter() + .map(|p| Some(p.content.clone())) + .collect(); + assert_eq!( + deferred, immediate, + "deferred renderer consumes the current shaped text" + ); + } + fn retired(frame: &Frame) { + assert_eq!( + frame.deferred_text(), + vec![None, None], + "no previous-frame paragraph ownership leaks across a replacement or hidden draw" + ); + } + let mut mounted = Mounted::new(ROW); + let initial = mounted.draw(mouse::Cursor::Unavailable); + let alpha = initial.point(0, 2); + let beta = initial.point(0, 7); + let before = mounted.state(); + let first = mounted.draw(mouse::Cursor::Available(alpha)); + live(&first, "Detail A"); + assert_eq!(mounted.state(), before); + // Rebuilding the Element / operating the actual editor does not drop Tree + // ownership before the renderer's later preparation stage. + live(&first, "Detail A"); + + mounted.publication = Some(Id::new_v4()); + mounted.diagnostics = vec![ + diagnostic("a", 6..10, "NEW_BETA", Severity::Error), + diagnostic("b", 6..10, "OTHER_PATH", Severity::Warning), + ]; + let now_blank = mounted.draw(mouse::Cursor::Available(alpha)); + assert!(now_blank.deferred_paragraphs.is_empty()); + retired(&first); + let replacement = mounted.draw(mouse::Cursor::Available(beta)); + live(&replacement, "NEW_BETA"); + mounted.path = "b".into(); + let other = mounted.draw(mouse::Cursor::Available(beta)); + live(&other, "OTHER_PATH"); + retired(&replacement); + mounted.publication = None; + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&other); + + mounted.publication = Some(Id::new_v4()); + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + mounted.hover_enabled = false; + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + mounted.hover_enabled = true; + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + assert!( + mounted + .draw(mouse::Cursor::Unavailable) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + mounted.viewport = Some(Rectangle::new(Point::new(-10., -10.), Size::new(1., 1.))); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + mounted.viewport = None; + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + mounted.source = Arc::from("different current snapshot"); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + mounted.source = Arc::from(ROW); + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + assert_eq!( + mounted.state(), + before, + "retention must not alter editor state or raw input clock" + ); + drop(mounted); + retired(&shown); +} + +#[test] +fn measured_footer_and_omitted_count_fit_actual_shaped_widths_and_ancestor_clip() { + for width in [100., 180., 520.] { + let mut mounted = Mounted::new(ROW); + mounted.width = width + PAD * 2.; + mounted.height = 432.; + mounted.diagnostics = (0..6) + .map(|n| { + let mut value = diagnostic( + "a", + 0..5, + &format!("OVERLAP_{n}"), + if n == 0 { + Severity::Warning + } else { + Severity::Error + }, + ); + value.diagnostic.message = + format!("Long 雨 message {}", "bounded details ".repeat(90)); + value + }) + .collect(); + let first = mounted.draw(mouse::Cursor::Unavailable); + let point = first.point(0, 2); + let frame = mounted.draw(mouse::Cursor::Available(point)); + let footer = frame + .paragraphs + .iter() + .find(|p| p.content.contains("4 of 6 shown")) + .expect("overlap footer must exist at admitted test viewport"); + assert!(footer.content.contains("2 more")); + assert!(footer.content.contains("Actions → Source problems")); + assert!( + paragraph_complete(footer, footer.clip), + "all shaped footer clusters must fit at width {width}: {footer:?}" + ); + let body = frame + .paragraphs + .iter() + .find(|p| p.content.contains("OVERLAP_")) + .unwrap(); + assert!( + body.content.starts_with("Error [OVERLAP_1]"), + "errors precede warning even when warning was published first" + ); + assert!(!body.content.contains("OVERLAP_0")); + for quad in frame.decoration_quads() { + assert!( + inside(quad.bounds, quad.clip), + "decoration outside ancestor layer: {quad:?}" + ); + } + let warning = Background::Color(Theme::Light.palette().warning); + let error = Background::Color(Theme::Light.palette().danger); + let colors: Vec<_> = frame + .decoration_quads() + .filter(|q| q.background == warning || q.background == error) + .map(|q| q.background) + .collect(); + assert_eq!(colors.len(), 6); + assert_eq!( + colors[0], warning, + "warnings painted below overlapping errors" + ); + assert!(colors[1..].iter().all(|color| *color == error)); + if width == 100. { + // In-process rejected old budget: same actual shaped footer clipped + // to the old 26px allowance must fail our completeness evaluator. + assert!(footer.clip.height > 26.); + assert!(!paragraph_complete( + footer, + Rectangle { + height: 26., + ..footer.clip + } + )); + } + // A genuinely narrower ancestor viewport, not a nominal child width. + let inset = if width > 100. { 9. } else { 0. }; + mounted.viewport = Some(Rectangle::new( + Point::new(ORIGIN.x + PAD + inset, ORIGIN.y + PAD), + Size::new(width - inset, 220.), + )); + let clipped = mounted.draw(mouse::Cursor::Available(point)); + for quad in clipped.decoration_quads() { + assert!(inside(quad.bounds, quad.clip)); + } + let footer = clipped + .paragraphs + .iter() + .find(|p| p.content.contains("4 of 6 shown")) + .expect("footer must remain usable at 220px effective height"); + assert!(paragraph_complete(footer, footer.clip)); + mounted.viewport = Some(Rectangle::new( + Point::new(ORIGIN.x + PAD, ORIGIN.y + PAD), + Size::new(width, 60.), + )); + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .paragraphs + .is_empty(), + "insufficient tooltip height is suppressed safely" + ); + } +} + +/// Composition witness using admitted App snapshots and the App's actual raw +/// input clock. Content is copied into the CPU renderer's real graphics editor; +/// this is not a native production-renderer or sensor-topology claim. +pub(crate) struct AppMounted(Mounted); +impl AppMounted { + pub(crate) fn new(snapshot: Snapshot<'_>, hover_enabled: bool, clock: Arc) -> Self { + let mut mounted = Self(Mounted::new(&snapshot.source)); + mounted.0.clock = clock; + mounted.refresh(snapshot, hover_enabled); + mounted + } + pub(crate) fn refresh(&mut self, snapshot: Snapshot<'_>, hover_enabled: bool) { + assert_eq!(self.0.content.text(), snapshot.source.as_ref()); + self.0.source = snapshot.source; + self.0.path = snapshot.path.to_owned(); + self.0.diagnostics = snapshot.diagnostics.to_vec(); + self.0.publication = Some(snapshot.publication); + self.0.hover_enabled = hover_enabled; + } + pub(crate) fn assert_hover(&mut self, expected: bool) { + let frame = self.0.draw(mouse::Cursor::Unavailable); + let point = frame.point(0, 2); + let frame = passive(&mut self.0, Some(point)); + assert!( + frame.decoration_quads().count() > 0, + "current source marks remain painted" + ); + if expected { + assert!(frame.tooltip().contains("Union diagnostic details")); + assert!( + frame.deferred_text().iter().any(|text| text + .as_ref() + .is_some_and(|text| text.contains("Union diagnostic details"))), + "actual weak paragraph remains alive after draw returns" + ); + } else { + assert!(frame.paragraphs.is_empty()); + assert!(frame.deferred_paragraphs.is_empty()); + } + } +} diff --git a/crates/oa-desktop/src/source_navigation/controller.rs b/crates/oa-desktop/src/source_navigation/controller.rs index 620a6988..1de342c0 100644 --- a/crates/oa-desktop/src/source_navigation/controller.rs +++ b/crates/oa-desktop/src/source_navigation/controller.rs @@ -10,9 +10,9 @@ pub enum SymbolEvent { DiagramUsages, Query(query::Action), Back, - Close, Choose { stamp: query::QueryStamp, + visit: Id, reference: bool, index: usize, }, @@ -34,7 +34,7 @@ pub struct HistoryEntry { pub cursor: Cursor, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Request { pub job: Id, pub interaction: Id, @@ -61,12 +61,15 @@ pub struct Reveal { } pub struct SymbolState { - pub open: bool, pub interaction: Id, pub raw_interaction: Arc, pub serial: u64, pub in_flight: Option, pub request: Option, + /// 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, pub cache: Option>, pub results: Option>, pub notice: Option, @@ -77,12 +80,12 @@ pub struct SymbolState { impl Default for SymbolState { fn default() -> Self { Self { - open: false, interaction: Id::new_v4(), raw_interaction: Arc::new(AtomicU64::new(0)), serial: 0, in_flight: None, request: None, + display_request: None, cache: None, results: None, notice: None, diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 9195b8cc..7bfd4b76 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1,4 +1,5 @@ mod source_symbols; +mod text_workspace; pub(crate) mod traceability; use crate::{ app::{App, Choice, FileOperation, Message, Page, Pending}, @@ -256,7 +257,7 @@ pub fn view(app: &App) -> View<'_, Message> { p.relations.len() )), action( - &format!("{} checks", app.issue_count()), + &format!("{} model checks", app.issue_count()), Message::Page(Page::Validation) ) ] @@ -1096,239 +1097,79 @@ fn source_files(app: &App) -> View<'_, Message> { } fn source(app: &App) -> View<'_, Message> { - use crate::source_navigation::Event as SourceEvent; - let (undo_label, undo_message) = match app.source_rename_undo_message() { - Some(message) => ("Undo rename", Some(message)), - None => ( - "Undo model", - (app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo), - ), - }; - let navigation: View<'_, Message> = if app.source_navigation.open { - column![ - row![ - text_input("Find literal text…", &app.source_navigation.query) - .id("source-find") - .on_input(|query| Message::SourceNavigation(SourceEvent::Query(query))) - .on_submit(Message::SourceNavigation(SourceEvent::Next(false))) - .padding(8) - .size(12) - .width(Fill), - action( - "Previous", - Message::SourceNavigation(SourceEvent::Next(true)) - ), - action("Next", Message::SourceNavigation(SourceEvent::Next(false))), - label(app.source_navigation.label()), - action("Close", app.source_find_close_message()), - ] - .spacing(6) - .align_y(iced::Center), - row![ - text_input("Line", &app.source_navigation.line) - .id("source-line") - .on_input(|line| Message::SourceNavigation(SourceEvent::Line(line))) - .on_submit(Message::SourceNavigation(SourceEvent::Go)) - .padding(8) - .size(12) - .width(80), - action("Go to line", Message::SourceNavigation(SourceEvent::Go)), - label("Enter or F3: next match · Shift F3: previous · Esc: close"), - ] - .spacing(6) - .align_y(iced::Center), - ] - .spacing(6) - .into() - } else { - column![].into() - }; - let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery { - let edits_block_recovery = - !app.source_workspace.applied && app.source_workspace.has_edits(); - let filename = path.file_name().unwrap_or_default().to_string_lossy(); - container( - column![ - tooltip( - text(format!("Interrupted save · {filename}")).size(15), - text(path.display().to_string()).size(12), - tooltip::Position::Bottom, - ), - text(if edits_block_recovery { - "Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them." - } else if app.source_workspace.applied { - "Restore files to their state before the interrupted save. Your current model and editor drafts are kept." - } else { - "Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save." - }) - .size(13), - button(text("Recover interrupted save").size(12)) - .padding(10) - .on_press_maybe( - (!edits_block_recovery && !app.busy).then_some(Message::SourceRecover), - ), - ] - .spacing(8), - ) - .padding(12) - .width(Fill) - .style(style::card) - .into() - } else { - column![].into() - }; - let mut diagnostics = column![].spacing(5); - let mut visible_diagnostics = 0; - for (index, issue) in app.source_diagnostics.iter().enumerate() { - // The persistent recovery banner supplies the action and explanation; - // repeating the internal journal path obscures ordinary diagnostics. - if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" { - continue; + use crate::app::text_workspace::DrawerTab; + use oa_core::source_project::SourceLocation; + let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems); + let publication = app + .text_workspace + .diagnostic_publication() + .filter(|publication| visible && publication.source() == app.source_key()); + let mut diagnostics = column![].spacing(3).width(Fill); + if visible { + if publication.is_none() && !app.source_diagnostics.is_empty() { + diagnostics = diagnostics + .push(text("Source changed. Waiting for current source checks…").size(12)); } - visible_diagnostics += 1; - diagnostics = diagnostics.push( - button( - text(format!( - "{}:{}:{} {}", - issue.path, - issue.diagnostic.line, - issue.diagnostic.column, - issue.diagnostic.message - )) - .size(12) - .color(match issue.diagnostic.severity { - validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64), - validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23), - }), - ) - .width(Fill) - .padding([4, 6]) - .style(style::nav) - .on_press(Message::SourceDiagnostic(index)), - ); - for related in &issue.related { - diagnostics = diagnostics.push(action( - &format!("Related declaration · {}", related.path), - Message::SourceRelated(related.clone()), - )); + if app.source_diagnostics.is_empty() { + diagnostics = diagnostics.push(text("No source problems reported.").size(12)); + } + for issue in &app.source_diagnostics { + let location = SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, + }; + let severity = match issue.diagnostic.severity { + validation::Severity::Error => "Error", + validation::Severity::Warning => "Warning", + }; + diagnostics = diagnostics.push( + button( + text(format!( + "{severity} · {}:{}:{} · {}", + issue.path, + issue.diagnostic.line, + issue.diagnostic.column, + issue.diagnostic.message, + )) + .size(12) + .wrapping(widget::text::Wrapping::WordOrGlyph), + ) + .width(Fill) + .padding([4, 6]) + .style(style::nav) + .on_press_maybe(app.text_diagnostic_message(location)), + ); + for related in &issue.related { + diagnostics = diagnostics.push( + button( + text(format!("Related declaration · {}", related.path)) + .size(12) + .wrapping(widget::text::Wrapping::WordOrGlyph), + ) + .width(Fill) + .padding([4, 12]) + .style(style::nav) + .on_press_maybe(app.text_diagnostic_message(related.clone())), + ); + } } } - let editor = widget::text_editor(&app.source_editor) - .id(app.source_editor_id()) - .style(style::source_editor) - .on_action(Message::SourceEdit) - .key_binding({ - let context = app.source_symbol_context(); - let rename = app.source_rename_begin(); - move |press| source_binding(press, &context, Some(&rename)) - }) - .font(Font::MONOSPACE) - .size(14) - .padding(16) - .height(Fill) - .highlight_with::( - app.source_snapshot.clone(), - crate::highlight::format, - ); - let header = container( - column![ - row![ - container(heading( - "Textual model", - "One model across files · comments and identities preserved" - )) - .width(Fill), - button(text("Apply all").size(12)) - .padding(10) - .on_press(Message::ApplySource), - action("Revert file", Message::RevertSource) - ] - .spacing(8) - .align_y(iced::Center), - row![ - action("Undo text", Message::SourceUndo), - action("Redo text", Message::SourceRedo), - button(text(undo_label).size(12)).padding([8,10]).style(style::nav) - .on_press_maybe(undo_message), - button(text("Redo model").size(12)).padding([8,10]).style(style::nav) - .on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)), - action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))), - label(if app.source_dirty() { - "Drafts pending" - } else { - "Model in sync" - }) - ] - .spacing(7) - .align_y(iced::Center), - recovery, - text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK), - label(if app.pending_recovery.is_some() && !app.source_workspace.applied { - "The previous graphical model remains available while these files are recovered." - } else if !app.source_workspace.applied { - "This project needs repair. Apply all adopts it; the previous graphical model remains available." - } else if app.source_workspace.detached_dirty() { - "A detached file has an unsaved draft. Reattach it or revert that file before saving." - } else { - "Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)." - }), - navigation, - row![ - action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))), - action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))), - action("Rename alias · F2", app.source_rename_message()), - action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)), - button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav) - .on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))), - ].spacing(7), - scrollable(diagnostics) - .height((visible_diagnostics as f32 * 32.).min(132.)) - ] - .spacing(12) - ) - .padding(18); - let editor = widget::sensor(editor) - .key(( - app.source_workspace.active.clone(), - app.source_reveal_ticket(), - )) - .on_show({ - let ticket = app.source_reveal_ticket(); - move |_| Message::SourceViewport(ticket) - }) - .on_resize({ - let ticket = app.source_reveal_ticket(); - move |_| Message::SourceViewport(ticket) - }); - let workspace: View<'_, Message> = if app.source_symbols.open { - row![ - container(editor).width(Fill).height(Fill), - container(scrollable(source_symbols::panel(app))) - .width(300) - .height(Fill), - ] - .height(Fill) - .into() - } else { - container(editor).width(Fill).height(Fill).into() - }; - source_layout(header.into(), workspace, app.window_size.height) + let guard = crate::diagnostic_input::Guard::new( + publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())), + diagnostics, + ); + // The complete byte/evidence comparison is shared by the summary and rows, + // once per visible References rebuild; no parser or second query is started. + let references_current = + app.text_workspace.drawer() == Some(DrawerTab::References) && app.source_results_current(); + text_workspace::view( + app, + source_symbols::panel(app, references_current), + guard.into(), + references_current, + ) } -fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( - header: View<'a, Message, iced::Theme, Renderer>, - workspace: View<'a, Message, iced::Theme, Renderer>, - window_height: f32, -) -> View<'a, Message, iced::Theme, Renderer> { - // Lint clears and repopulates diagnostics asynchronously. Reserve the same - // outer slot so a pointer aimed at the editor cannot land in the header. - column![ - container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)), - workspace, - ] - .height(Fill) - .into() -} pub(crate) fn source_binding( press: widget::text_editor::KeyPress, context: &crate::source_navigation::SymbolContext, @@ -1392,8 +1233,7 @@ pub(crate) mod source_tests { use iced::advanced::{ Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget, }; - use iced::widget::column; - use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector}; + use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation}; // Measure real Iced paragraphs/editor text without constructing a window or GPU. pub(crate) struct FontLayout; @@ -1438,207 +1278,6 @@ pub(crate) mod source_tests { fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} } - #[derive(Default)] - struct Geometry { - header: Option<(Rectangle, Rectangle, Vector)>, - editor: Option, - } - 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, - 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::::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::::with_text("// 雨\r\n"); - let host = Size::new(720., 540.); - let mut tree = core_widget::Tree::empty(); - let mut saved = None; - for diagnostics in [6, 7] { - let mut ui = mounted_source(&content, diagnostics, true, 700.); - tree.diff(ui.as_widget()); - let node = ui.as_widget_mut().layout( - &mut tree, - &FontLayout, - &layout::Limits::new(Size::ZERO, host), - ); - let mut geometry = Geometry::default(); - ui.as_widget_mut() - .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); - let (bounds, contents, _) = geometry.header.unwrap(); - assert!( - contents.height > bounds.height, - "fixture must actually overflow" - ); - if saved.is_none() { - let mut messages = Vec::new(); - let mut shell = Shell::new(&mut messages); - ui.as_widget_mut().update( - &mut tree, - &iced::Event::Mouse(iced::mouse::Event::WheelScrolled { - delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. }, - }), - Layout::new(&node), - iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)), - &FontLayout, - &mut iced::advanced::clipboard::Null, - &mut shell, - &Rectangle::with_size(host), - ); - assert!(shell.is_event_captured()); - } - let mut geometry = Geometry::default(); - ui.as_widget_mut() - .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); - let offset = geometry.header.unwrap().2; - assert!(offset.y > 0., "the actual mounted Scrollable must move"); - if let Some(before) = saved { - assert_eq!(offset, before); - } else { - saved = Some(offset); - } - } - } - #[cfg(target_os = "windows")] #[test] fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() { @@ -1892,6 +1531,16 @@ fn checks(app: &App) -> View<'_, Message> { scrollable(out.padding(24)).into() } +#[cfg(all(test, debug_assertions))] +pub(crate) fn captured_text_drawer_resize(app: &App) -> Message { + text_workspace::capture_drawer_resize(app) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) fn captured_source_reference_stale_summary(app: &App) -> bool { + text_workspace::summary_visibility_tests::rendered_stale(app) +} + #[cfg(test)] mod tests { use super::short; diff --git a/crates/oa-desktop/src/views/source_symbols.rs b/crates/oa-desktop/src/views/source_symbols.rs index bdcbe734..ae594c62 100644 --- a/crates/oa-desktop/src/views/source_symbols.rs +++ b/crates/oa-desktop/src/views/source_symbols.rs @@ -2,80 +2,151 @@ use crate::{ app::{App, Message}, source_navigation::{ SymbolEvent, - query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position}, + query::{Declaration, Disposition, Evidence, Identity, Membership, Position}, }, style, }; use iced::{ Element, Fill, - widget::{Space, button, column, container, row, scrollable, text, tooltip}, + widget::{button, column, container, scrollable, text, tooltip}, }; -pub(super) fn panel(app: &App) -> Element<'_, Message> { +pub(super) fn panel(app: &App, current: bool) -> Element<'_, Message> { + use crate::app::text_workspace::{DrawerTab, Event}; + use std::collections::BTreeSet; let state = &app.source_symbols; - if !state.open - || (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none()) - { - return column![].into(); + let visible = app.text_workspace.drawer() == Some(DrawerTab::References); + let presentation = app.text_workspace.references(); + let mut content = column![].spacing(0).width(Fill); + if visible && let Some(notice) = &state.notice { + content = content.push(text(notice.clone()).size(12)); } - let title = state.results.as_ref().map_or_else( - || "Source symbols".to_owned(), - |results| { - let action = match results.action { - query::Action::Definition => "Definitions", - query::Action::References => "References", - }; - results.alias.as_ref().map_or_else( - || action.to_owned(), - |alias| format!("{action} · @{}", preview(alias, 40)), - ) - }, - ); - let mut content = column![ - row![ - text(title).size(13), - Space::new().width(Fill), - button(text("Close").size(11)) - .padding([3, 8]) + if visible && let Some(results) = &state.results { + let stale = !current; + let enabled = !stale && app.source_navigation_available(); + let files: BTreeSet<_> = results + .definitions + .iter() + .map(|row| row.path.as_str()) + .chain(results.references.iter().map(|row| row.path.as_str())) + .collect(); + let mut ordered: Vec<_> = files.iter().copied().collect(); + // Keep the query's captured origin first even after navigating elsewhere. + if let Some(origin) = state + .display_request + .as_ref() + .map(|request| request.path.as_str()) + { + ordered.sort_by_key(|path| (*path != origin, *path)); + } + for path in ordered { + let collapsed = presentation.collapsed(path); + let count = results + .definitions + .iter() + .filter(|row| row.path == path) + .count() + + results + .references + .iter() + .filter(|row| row.path == path) + .count(); + let suffix = distinguishing_path(path, &files); + content = content.push( + button( + text(format!( + "{} {suffix} · {count}", + if collapsed { "▸" } else { "▾" } + )) + .size(12) + .wrapping(iced::widget::text::Wrapping::None), + ) + .height(24) + .width(Fill) + .padding([2, 6]) .style(style::nav) - .on_press(app.source_symbol_message(SymbolEvent::Close)), - ] - .align_y(iced::Center) - ] - .spacing(5); - - if let Some(notice) = &state.notice { - content = content.push(super::label(preview(notice, 240))); - } - if state.in_flight.is_some() { - content = content.push(super::label("Finding source symbols…")); - } else if let Some(results) = &state.results { - let stale = results.stamp.snapshot != app.source_key(); - if stale { - content = content.push(super::label( - "Stale results — source changed. Run Definition or References again.", - )); + .on_press_maybe(app.source_navigation_available().then(|| { + Message::TextWorkspace(Event::ToggleFile { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + path: path.to_owned(), + }) + })), + ); + if collapsed { + continue; + } + for (index, definition) in results + .definitions + .iter() + .enumerate() + .filter(|(_, row)| row.path == path) + { + content = content.push(location_row( + declaration_title(&definition.declaration), + path, + definition.position, + provenance( + definition.membership, + definition.evidence, + definition.declaration.identity, + false, + ), + enabled.then(|| { + app.source_symbol_message(SymbolEvent::Choose { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + reference: false, + index, + }) + }), + )); + } + for (index, reference) in results + .references + .iter() + .enumerate() + .filter(|(_, row)| row.path == path) + { + let title = reference.context.as_ref().map_or_else( + || format!("@{} · lexical use", preview(&reference.alias, 48)), + |context| format!("use · {}", declaration_title(context)), + ); + content = content.push(location_row( + title, + path, + reference.position, + provenance( + reference.membership, + reference.evidence, + reference + .context + .as_ref() + .map_or(Identity::Missing, |context| context.identity), + true, + ), + enabled.then(|| { + app.source_symbol_message(SymbolEvent::Choose { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + reference: true, + index, + }) + }), + )); + } + // Full path is readable by ordinary scrolling, independently of hover. + content = content.push( + container( + text(path.to_owned()) + .font(iced::Font::MONOSPACE) + .size(12) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ) + .padding([3, 8]), + ); } let scope = &results.scope; - content = content.push(super::label(format!( - "{} · {} · {} included / {} detached / {} unverified files · {} scope issues", - if scope.partial() { - "Partial scope" - } else { - "Complete include scope" - }, - if !scope.applied { - "Unapplied drafts" - } else if scope.validated { - "Validated sources" - } else { - "Draft sources" - }, - scope.included_files, - scope.detached_files, - scope.unverified_files, - scope.issues.len(), - ))); let explanation = match results.disposition { Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.", Disposition::NoDefinition => "No matching declaration in the loaded sources.", @@ -87,89 +158,113 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> { "Lexical uses of this alias; these do not establish a semantic target." } }; - content = content.push(super::label(explanation)); - if results.total_definitions > 1 { - content = content.push(super::label(format!( - "Alias collision: {} declarations. Uses are not attributed to a single declaration.", - results.total_definitions, - ))); - } - let enabled = !stale && !app.busy; - let mut rows = column![super::label(counts( - "Declarations", - results.definitions.len(), - results.total_definitions, - results.omitted_definitions, - ))] - .spacing(4); - for (index, definition) in results.definitions.iter().enumerate() { - rows = rows.push(location_row( - declaration_title(&definition.declaration), - &definition.path, - definition.position, - provenance( - definition.membership, - definition.evidence, - definition.declaration.identity, - false, - ), - enabled.then(|| { - app.source_symbol_message(SymbolEvent::Choose { - stamp: results.stamp, - reference: false, - index, - }) - }), - )); - } - rows = rows.push(super::label(counts( - "Lexical uses", - results.references.len(), - results.total_references, - results.omitted_references, - ))); - for (index, reference) in results.references.iter().enumerate() { - let title = reference.context.as_ref().map_or_else( - || format!("@{} · lexical use", preview(&reference.alias, 48)), - |context| { - format!( - "@{} · inside {}", - preview(&reference.alias, 48), - declaration_title(context) - ) - }, + content = content.push(text(explanation).size(12)); + if stale { + content = content.push( + text("Source changed. Run Definition or References again for current locations.") + .size(12), ); - rows = rows.push(location_row( - title, - &reference.path, - reference.position, - provenance( - reference.membership, - reference.evidence, - reference - .context - .as_ref() - .map_or(Identity::Missing, |context| context.identity), - true, - ), - enabled.then(|| { - app.source_symbol_message(SymbolEvent::Choose { - stamp: results.stamp, - reference: true, - index, - }) - }), - )); } - let row_count = results.definitions.len() + results.references.len(); - content = - content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0))); + if results.total_definitions > 1 { + content = content.push( + text("Alias collision: uses are not attributed to a single declaration.").size(12), + ); + } + content = content + .push( + text(format!( + "{} · {} included / {} detached / {} unverified files · {} scope issues", + if scope.partial() { + "Partial include scope" + } else { + "Complete include scope" + }, + scope.included_files, + scope.detached_files, + scope.unverified_files, + scope.issues.len() + )) + .size(12), + ) + .push( + text(if !scope.applied { + "Unapplied drafts" + } else if scope.validated { + "Validated sources" + } else { + "Draft sources" + }) + .size(12), + ) + .push( + text(counts( + "Declarations", + results.definitions.len(), + results.total_definitions, + results.omitted_definitions, + )) + .size(12), + ) + .push( + text(counts( + "Lexical uses", + results.references.len(), + results.total_references, + results.omitted_references, + )) + .size(12), + ); } - container(content) - .padding(8) - .width(Fill) - .style(style::subtle) - .into() + let identity = if visible { + state + .display_request + .as_ref() + .map(|request| (request.job, app.text_workspace.drawer_visit())) + } else { + None + }; + let guarded = crate::diagnostic_input::Guard::new(identity, content); + let mut body = scrollable( + container(guarded) + .width(Fill) + .padding(iced::Padding::ZERO.right(14)), + ) + .id(app.text_workspace.reference_scroll_id()) + .height(Fill) + .width(Fill); + if let Some(stamp) = presentation.stamp() { + let visit = app.text_workspace.drawer_visit(); + body = body.on_scroll(move |viewport| { + Message::TextWorkspace(Event::ReferenceScrolled { + stamp, + visit, + y: viewport.absolute_offset().y, + }) + }); + } + body.into() +} + +/// Shortest suffix that still distinguishes duplicate basenames and suffix paths. +fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str { + let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1)); + for start in starts.collect::>().into_iter().rev() { + let candidate = &path[start..]; + if files + .iter() + .filter(|other| { + **other == candidate + || other + .strip_suffix(candidate) + .is_some_and(|prefix| prefix.ends_with('/')) + }) + .count() + == 1 + { + return candidate; + } + } + path } fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String { @@ -223,23 +318,24 @@ fn location_row( ) -> Element<'static, Message> { let line = position.line.saturating_add(1); let column = position.byte_column.saturating_add(1); - let location = format!("{}:{line}:{column}", preview(path, 72)); + let full = + format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)"); tooltip( button( - column![ - text(title).size(12), - super::label(format!("{location} · {status}")) - ] - .spacing(2), + container( + text(format!("{line}:{column} {title} · {status}")) + .size(12) + .wrapping(iced::widget::text::Wrapping::None), + ) + .clip(true) + .width(Fill), ) - .padding([4, 6]) + .padding([3, 12]) + .height(28) .width(Fill) .style(style::nav) .on_press_maybe(event), - text(format!( - "{path}:{line}:{column} (1-based line and UTF-8 byte column)" - )) - .size(11), + text(full).size(12), tooltip::Position::Top, ) .into() @@ -258,3 +354,32 @@ fn preview(value: &str, limit: usize) -> String { } result } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() { + let files = [ + "a/core.arcadia", + "b/core.arcadia", + "core.arcadia", + "救援/actors.arcadia", + ] + .into_iter() + .collect(); + assert_eq!( + distinguishing_path("a/core.arcadia", &files), + "a/core.arcadia" + ); + assert_eq!( + distinguishing_path("b/core.arcadia", &files), + "b/core.arcadia" + ); + assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia"); + assert_eq!( + distinguishing_path("救援/actors.arcadia", &files), + "actors.arcadia" + ); + } +} diff --git a/crates/oa-desktop/src/views/text_workspace.rs b/crates/oa-desktop/src/views/text_workspace.rs new file mode 100644 index 00000000..7fa09536 --- /dev/null +++ b/crates/oa-desktop/src/views/text_workspace.rs @@ -0,0 +1,1175 @@ +//! Compact Text workspace presentation. Visibility is explicit controller state; +//! rebuilding diagnostics or query results must never change editor geometry. +use crate::app::text_workspace::{Command, DrawerContext, DrawerTab, Event}; +use crate::{ + app::{App, Message}, + style, +}; +use iced::{ + Element, Fill, Font, Length, + widget::{self, button, column, container, row, scrollable, text, text_input}, +}; +use std::{cell::Cell, rc::Rc}; + +pub(crate) const STRIP_HEIGHT: f32 = 42.; +pub(crate) const FIND_HEIGHT: f32 = 36.; +pub(crate) const DRAWER_HEIGHT: f32 = 180.; +pub(crate) const STATUS_HEIGHT: f32 = 22.; +const EDITOR_PADDING: f32 = 16.; + +/// The caller supplies one persistent diagnostic Guard, including when hidden +/// (None publication and empty child). References is empty while hidden; its +/// grouping and scroll restoration are controller-owned. +pub(super) fn view<'a>( + app: &'a App, + references: Element<'a, Message>, + problems: Element<'a, Message>, + references_current: bool, +) -> Element<'a, Message> { + let tab = app.text_workspace.drawer(); + let measured_drawer = Rc::new(Cell::new(DRAWER_HEIGHT)); + let strip = row![ + container( + text(app.source_workspace.active.clone()) + .font(Font::MONOSPACE) + .size(12) + .wrapping(widget::text::Wrapping::None) + ) + .width(Fill) + .clip(true), + compact_action("Find", app.text_command_message(Command::Find)), + crate::context_menu::Area::left( + button(text("Actions ▾").size(12)) + .padding([4, 8]) + .style(style::nav) + .on_press(app.text_menu_message(iced::Point::ORIGIN)), + |anchor| app.text_menu_message(anchor), + ), + compact_action("Apply all", Message::ApplySource), + ] + .spacing(6) + .align_y(iced::Center); + let find: Element<'a, Message> = if app.source_navigation.open { + find_bar(app) + } else { + widget::Space::new().into() + }; + let editor = widget::text_editor(&app.source_editor) + .id(app.source_editor_id()) + .style(style::source_editor) + .on_action(Message::SourceEdit) + .key_binding({ + let context = app.source_symbol_context(); + let rename = app.source_rename_begin(); + move |press| super::source_binding(press, &context, Some(&rename)) + }) + .font(Font::MONOSPACE) + .size(14) + .padding(EDITOR_PADDING) + .height(Fill) + .highlight_with::( + app.source_snapshot.clone(), + crate::highlight::format, + ); + let editor = crate::source_annotations::Decorations::new( + editor, + &app.source_editor, + app.source_annotation_snapshot(), + iced::Padding::new(EDITOR_PADDING), + app.source_navigation_available(), + ); + 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 (errors, warnings) = diagnostic_counts(app); + let summary: Element<'a, Message> = match tab { + Some(DrawerTab::References) => reference_summary(app, references_current), + Some(DrawerTab::Problems) => text(format!( + "{errors} source errors · {warnings} warnings · choose a location for details" + )) + .size(12) + .wrapping(widget::text::Wrapping::None) + .into(), + Some(DrawerTab::Status) => text("Source adoption, recovery and text/model undo") + .size(12) + .wrapping(widget::text::Wrapping::None) + .into(), + None => widget::Space::new().into(), + }; + let chrome: Element<'a, Message> = if tab.is_some() { + let mut tabs = row![].spacing(4).align_y(iced::Center); + for (destination, title) in [ + (DrawerTab::References, "References"), + (DrawerTab::Problems, "Problems"), + (DrawerTab::Status, "Status"), + ] { + tabs = tabs.push( + button(text(title).size(12)) + .padding([4, 8]) + .style(if tab == Some(destination) { + style::selected + } else { + style::nav + }) + .on_press(Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + destination, + ))), + ); + } + tabs.push(widget::Space::new().width(Fill)) + .push(resize_button( + "Smaller", + measured_drawer.clone(), + -60., + app.text_drawer_context(), + )) + .push(resize_button( + "Larger", + measured_drawer.clone(), + 60., + app.text_drawer_context(), + )) + .push(compact_action( + "Hide", + Message::TextWorkspace(Event::HideDrawer(app.text_drawer_context())), + )) + .into() + } else { + widget::Space::new().into() + }; + let status: Element<'a, Message> = if tab == Some(DrawerTab::Status) { + status_body(app) + } else { + widget::Space::new().into() + }; + let body = widget::Column::from_vec(vec![ + container(references) + .width(Fill) + .height(if tab == Some(DrawerTab::References) { + Length::Fill + } else { + Length::Fixed(0.) + }) + .clip(true) + .into(), + tab_slot( + problems, + tab == Some(DrawerTab::Problems), + "text-problems-body", + ), + tab_slot(status, tab == Some(DrawerTab::Status), "text-status-body"), + ]) + .width(Fill) + .height(Fill); + let drawer = widget::Column::from_vec(vec![ + container(chrome).width(Fill).height(32).clip(true).into(), + container(summary).width(Fill).height(24).clip(true).into(), + body.into(), + ]) + .width(Fill) + .height(Fill); + let mut states = Vec::new(); + if app.pending_recovery.is_some() { + states.push("Recovery"); + } + if !app.source_workspace.applied { + states.push("Repair needed"); + } + if app.source_workspace.detached_dirty() { + states.push("Detached draft"); + } + if app.source_dirty() { + states.push("Drafts pending"); + } + let state = match states.as_slice() { + [] => "Model in sync".to_owned(), + [only] => (*only).to_owned(), + [first, rest @ ..] => format!("{first} +{} conditions", rest.len()), + }; + let source_status = button( + text(format!( + "{errors} errors · {warnings} warnings · {state} · Status…" + )) + .size(12), + ) + .padding([2, 8]) + .width(Fill) + .height(STATUS_HEIGHT) + .style(style::nav) + .on_press(Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Status, + ))); + layout( + container(strip) + .width(Fill) + .height(Fill) + .padding([4, 8]) + .style(style::panel) + .into(), + find, + editor.into(), + container(drawer).style(style::panel).into(), + source_status.into(), + LayoutSettings { + drawer_context: app.text_drawer_context(), + find_open: app.source_navigation.open, + drawer_height: tab.map(|_| app.text_workspace.drawer_height()), + measured_drawer, + }, + ) +} + +fn resize_button( + label: &'static str, + height: Rc>, + delta: f32, + context: DrawerContext, +) -> Element<'static, Message> { + widget::tooltip( + button(text(label).size(12)) + .padding([4, 8]) + .style(style::nav) + .on_press_with(move || { + Message::TextWorkspace(Event::ResizeDrawer(context, height.get() + delta)) + }), + text(if delta < 0. { + "Smaller drawer · Alt Page Up" + } else { + "Larger drawer · Alt Page Down" + }) + .size(12), + widget::tooltip::Position::Top, + ) + .into() +} + +fn tab_slot<'a>( + body: Element<'a, Message>, + active: bool, + id: &'static str, +) -> Element<'a, Message> { + container( + scrollable( + container(body) + .width(Fill) + .padding(iced::Padding::ZERO.right(14)), + ) + .id(id) + .height(Fill) + .width(Fill), + ) + .width(Fill) + .height(if active { + Length::Fill + } else { + Length::Fixed(0.) + }) + .clip(true) + .into() +} + +fn find_bar(app: &App) -> Element<'_, Message> { + use crate::source_navigation::Event as Find; + container( + row![ + text_input("Find literal text…", &app.source_navigation.query) + .id("source-find") + .on_input(|value| Message::SourceNavigation(Find::Query(value))) + .on_submit(Message::SourceNavigation(Find::Next(false))) + .size(12) + .padding(5) + .width(Fill), + compact_action("Previous", Message::SourceNavigation(Find::Next(true))), + compact_action("Next", Message::SourceNavigation(Find::Next(false))), + text(app.source_navigation.label()).size(12), + text_input("Line", &app.source_navigation.line) + .id("source-line") + .on_input(|value| Message::SourceNavigation(Find::Line(value))) + .on_submit(Message::SourceNavigation(Find::Go)) + .size(12) + .padding(5) + .width(58), + compact_action("Go", Message::SourceNavigation(Find::Go)), + compact_action("Close", app.source_find_close_message()), + ] + .spacing(4) + .align_y(iced::Center), + ) + .width(Fill) + .height(Fill) + .padding([2, 8]) + .into() +} + +fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>( + app: &'a App, + current: bool, +) -> Element<'a, Message, iced::Theme, Renderer> { + let Some(results) = &app.source_symbols.results else { + return text(if app.source_symbols.in_flight.is_some() { + "Finding source symbols…" + } else { + "Run References or Definition from Actions or the editor shortcuts." + }) + .size(12) + .wrapping(widget::text::Wrapping::None) + .into(); + }; + let subject = results.alias.as_deref().map_or_else( + || "Source symbols".to_owned(), + |alias| format!("@{}", super::short(alias, 32)), + ); + let value = format!( + "{subject} · {} declarations · {} uses", + results.total_definitions, results.total_references + ); + let mut flags = Vec::new(); + if !current { + flags.push("Stale"); + } + if results.scope.partial() { + flags.push("Partial"); + } + if results.total_definitions > 1 { + flags.push("Collision"); + } + if results.scope.detached_files > 0 { + flags.push("Detached"); + } + if results.scope.unverified_files > 0 { + flags.push("Unverified"); + } + let detail = text(value).size(12).wrapping(widget::text::Wrapping::None); + if flags.is_empty() { + return detail.into(); + } + // Exception status owns its width; a long alias can only clip the detail. + row![ + text(flags.join(" · ")) + .size(12) + .wrapping(widget::text::Wrapping::None), + container(detail).width(Fill).clip(true), + ] + .spacing(8) + .align_y(iced::Center) + .width(Fill) + .into() +} + +fn compact_action(label: &str, message: Message) -> widget::Button<'_, Message> { + button(text(label.to_owned()).size(12)) + .padding([4, 8]) + .style(style::nav) + .on_press(message) +} + +/// Conditions accumulate: recovery must not conceal detached drafts or repair. +fn status_body(app: &App) -> Element<'_, Message> { + let workspace = &app.source_workspace; + let mut body = column![ + text(workspace.active.clone()).font(Font::MONOSPACE).size(12).wrapping(widget::text::Wrapping::WordOrGlyph), + text("Resize this drawer with Smaller / Larger or Alt Page Up / Alt Page Down.").size(13), + text("Undo text affects only this file's draft. Undo model restores accepted source files together (100 changes / about 32 MiB). Undo rename restores the accepted rename transaction when available.").size(13), + row![compact_action("Undo text", app.text_command_message(Command::UndoText)), compact_action("Redo text", app.text_command_message(Command::RedoText))].spacing(6), + ].spacing(8).width(Fill); + if let Some(path) = &app.pending_recovery { + let blocked = !workspace.applied && workspace.has_edits(); + body = body.push(text(format!("Interrupted save · {}", path.display())).size(13).wrapping(widget::text::Wrapping::WordOrGlyph)) + .push(text(if blocked { + "Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them." + } else if 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)) + .push(button(text("Recover interrupted save").size(12)).padding([4, 8]).on_press_maybe((!blocked && !app.busy).then_some(Message::SourceRecover))); + } + if !workspace.applied { + body = body.push(text("Project needs repair. The previous graphical model remains available. Apply all validates and adopts the loaded reachable files as one model change.").size(13)); + if workspace.has_edits() { + body = body.push(text("Edited files are retained as drafts. Revert each edited file before recovering an interrupted save; reverting discards that file's edits.").size(13)); + } + } + if workspace.detached_dirty() { + body = body.push(text("A detached file has an unsaved draft. Reattach it or revert that file before saving. Closing a detached buffer never deletes its file.").size(13)); + } + if app.source_dirty() { + body = body.push(text("Drafts pending. The graphical views continue to show the accepted model until Apply all succeeds.").size(13)); + } + body = body.push( + row![ + compact_action("Apply all", Message::ApplySource), + compact_action("Revert file", app.text_command_message(Command::RevertFile)), + compact_action("Save all", Message::Save(false)), + ] + .spacing(6), + ); + body.into() +} + +fn diagnostic_counts(app: &App) -> (usize, usize) { + app.source_diagnostics + .iter() + .fold((0, 0), |(errors, warnings), issue| { + match issue.diagnostic.severity { + oa_core::validation::Severity::Error => (errors + 1, warnings), + oa_core::validation::Severity::Warning => (errors, warnings + 1), + } + }) +} + +/// All five positions exist on every rebuild. In particular, the editor never +/// changes siblings or wrapper types when Find, diagnostics or results change. +pub(crate) fn layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( + strip: Element<'a, Message, iced::Theme, Renderer>, + find: Element<'a, Message, iced::Theme, Renderer>, + editor: Element<'a, Message, iced::Theme, Renderer>, + drawer: Element<'a, Message, iced::Theme, Renderer>, + status: Element<'a, Message, iced::Theme, Renderer>, + settings: LayoutSettings, +) -> Element<'a, Message, iced::Theme, Renderer> { + Element::new(WorkspaceLayout { + children: vec![ + container(strip) + .id("text-workspace-strip") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(find) + .id("text-workspace-find") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(editor) + .id("text-workspace-editor") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(drawer) + .id("text-workspace-drawer") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(status) + .id("text-workspace-status") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + ], + settings, + }) +} + +pub(crate) struct LayoutSettings { + pub drawer_context: DrawerContext, + pub find_open: bool, + pub drawer_height: Option, + /// Layout publishes the effective height so Smaller immediately shrinks a + /// previously oversized request after a window resize. + pub measured_drawer: Rc>, +} + +fn slot_heights(height: f32, find: bool, requested: Option) -> [f32; 5] { + let height = height.max(0.); + let strip = STRIP_HEIGHT.min(height); + let status = STATUS_HEIGHT.min(height - strip); + let find = if find { + FIND_HEIGHT.min(height - strip - status) + } else { + 0. + }; + let available = (height - strip - status - find).max(0.); + let drawer = requested.map_or(0., |requested| { + let maximum = (available - 240.).max(DRAWER_HEIGHT).min(available); + let requested = if requested.is_finite() { + requested.max(DRAWER_HEIGHT) + } else { + DRAWER_HEIGHT + }; + requested.min(maximum) + }); + [strip, find, available - drawer, drawer, status] +} + +struct WorkspaceLayout<'a, Renderer> { + children: Vec>, + settings: LayoutSettings, +} + +impl + iced::advanced::Widget for WorkspaceLayout<'_, Renderer> +{ + fn children(&self) -> Vec { + self.children + .iter() + .map(iced::advanced::widget::Tree::new) + .collect() + } + fn diff(&self, tree: &mut iced::advanced::widget::Tree) { + tree.diff_children(&self.children); + } + fn size(&self) -> iced::Size { + iced::Size::new(Fill, Fill) + } + fn layout( + &mut self, + tree: &mut iced::advanced::widget::Tree, + renderer: &Renderer, + limits: &iced::advanced::layout::Limits, + ) -> iced::advanced::layout::Node { + let size = limits.resolve(Fill, Fill, iced::Size::ZERO); + let heights = slot_heights( + size.height, + self.settings.find_open, + self.settings.drawer_height, + ); + self.settings.measured_drawer.set(heights[3]); + let mut y = 0.; + let nodes = self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(heights) + .map(|((child, state), height)| { + let slot = iced::Size::new(size.width, height); + let node = child + .as_widget_mut() + .layout( + state, + renderer, + &iced::advanced::layout::Limits::new(slot, slot), + ) + .move_to(iced::Point::new(0., y)); + y += height; + node + }) + .collect(); + iced::advanced::layout::Node::with_children(size, nodes) + } + fn operate( + &mut self, + tree: &mut iced::advanced::widget::Tree, + layout: iced::advanced::Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced::advanced::widget::Operation, + ) { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + for ((child, state), layout) in self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + { + child + .as_widget_mut() + .operate(state, layout, renderer, operation); + } + }); + } + fn update( + &mut self, + tree: &mut iced::advanced::widget::Tree, + event: &iced::Event, + layout: iced::advanced::Layout<'_>, + cursor: iced::mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn iced::advanced::Clipboard, + shell: &mut iced::advanced::Shell<'_, Message>, + viewport: &iced::Rectangle, + ) { + // Local drawer commands. Popup is the persistent outer host and owns + // keyboard input while a menu is open; closed drawers claim no keys. + if self.settings.drawer_height.is_some() + && let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key, modifiers, .. + }) = event + && *modifiers == iced::keyboard::Modifiers::ALT + { + let delta = match key { + iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) => Some(-60.), + iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) => Some(60.), + _ => None, + }; + if let Some(delta) = delta { + shell.publish(Message::TextWorkspace(Event::ResizeDrawer( + self.settings.drawer_context, + self.settings.measured_drawer.get() + delta, + ))); + shell.capture_event(); + return; + } + } + // Preserve delivery to the hidden permanent Guard: it may need to + // swallow an armed release. Hidden content is emptied by the caller. + for ((child, state), layout) in self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + { + child.as_widget_mut().update( + state, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + } + fn mouse_interaction( + &self, + tree: &iced::advanced::widget::Tree, + layout: iced::advanced::Layout<'_>, + cursor: iced::mouse::Cursor, + viewport: &iced::Rectangle, + renderer: &Renderer, + ) -> iced::mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child + .as_widget() + .mouse_interaction(state, layout, cursor, viewport, renderer) + }) + .max() + .unwrap_or_default() + } + fn draw( + &self, + tree: &iced::advanced::widget::Tree, + renderer: &mut Renderer, + theme: &iced::Theme, + style: &iced::advanced::renderer::Style, + layout: iced::advanced::Layout<'_>, + cursor: iced::mouse::Cursor, + viewport: &iced::Rectangle, + ) { + if let Some(viewport) = layout.bounds().intersection(viewport) { + 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 iced::advanced::widget::Tree, + layout: iced::advanced::Layout<'b>, + renderer: &Renderer, + viewport: &iced::Rectangle, + translation: iced::Vector, + ) -> Option> { + iced::advanced::overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) + } +} + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::*; + use crate::views::source_tests::FontLayout; + use iced::widget::column; + use iced::{ + Size, + advanced::{ + layout, + widget::{Tree, operation::Focusable}, + }, + }; + + #[test] + fn five_slots_keep_editor_state_and_selection_through_explicit_and_passive_changes() { + let mut content = + widget::text_editor::Content::::with_text("// 雨\r\n@assess\r\n"); + content.move_to(widget::text_editor::Cursor { + position: widget::text_editor::Position { line: 1, column: 7 }, + selection: Some(widget::text_editor::Position { line: 1, column: 1 }), + }); + let expected_cursor = content.cursor(); + let mut tree = Tree::empty(); + let mut state_address = None; + for (find, drawer, issues) in [ + (false, false, 0), + (false, false, 100), + (true, false, 1), + (true, true, 100), + (true, true, 0), + (false, true, 1), + (false, false, 0), + ] { + let mut notices = column![]; + for index in 0..issues { + notices = notices.push(text(format!("Diagnostic {index}"))); + } + let editor = widget::text_editor(&content) + .on_action(Message::SourceEdit) + .height(Fill) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .highlight_with::( + content.text().into(), + crate::highlight::format, + ); + let editor = widget::sensor(editor) + .key(("救援/assess.arcadia".to_owned(), None::)) + .on_show(|_| Message::SourceViewport(None)) + .on_resize(|_| Message::SourceViewport(None)); + let mut view = layout( + text("救援/assess.arcadia").into(), + text("Find").into(), + editor.into(), + scrollable(notices).height(Fill).into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: find, + drawer_height: drawer.then_some(DRAWER_HEIGHT), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, Size::new(720., 540.)), + ); + assert_eq!(node.children().len(), 5); + let editor_bounds = node.children()[2].bounds(); + assert_eq!( + editor_bounds.y, + STRIP_HEIGHT + if find { FIND_HEIGHT } else { 0. } + ); + assert_eq!( + editor_bounds.height, + 540. - STRIP_HEIGHT + - STATUS_HEIGHT + - if find { FIND_HEIGHT } else { 0. } + - if drawer { DRAWER_HEIGHT } else { 0. } + ); + assert!(editor_bounds.height >= 240.); + // Container delegates to the permanent sensor, whose child is the + // actual highlighted editor, matching the production wrapper path. + let state = tree.children[2].children[0] + .state + .downcast_mut::>(); + let address = state as *mut _ as usize; + if let Some(expected) = state_address { + assert_eq!( + address, expected, + "the actual editor state must be retained" + ); + assert!(state.is_focused()); + } else { + state.focus(); + state_address = Some(address); + } + assert_eq!(content.cursor(), expected_cursor); + assert_eq!(content.selection().as_deref(), Some("assess")); + } + } + + #[test] + fn drawer_clamps_to_actual_center_allocation_including_oversize_requests() { + for (host, find, request, expected_drawer, expected_editor) in [ + (Size::new(720., 480.), true, 1200., 180., 200.), + (Size::new(720., 540.), true, 1200., 200., 240.), + (Size::new(1180., 890.), false, 1200., 586., 240.), + (Size::new(1180., 890.), true, 280., 280., 510.), + (Size::new(720., 540.), false, 180., 180., 296.), + ] { + let measured = Rc::new(Cell::new(0.)); + let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout( + text("Strip").into(), + text("Find").into(), + text("Editor").into(), + text("Drawer").into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: find, + drawer_height: Some(request), + measured_drawer: measured.clone(), + }, + ); + let mut tree = Tree::new(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, host), + ); + assert_eq!(node.size(), host); + assert_eq!(node.children().len(), 5); + assert_eq!(node.children()[3].bounds().height, expected_drawer); + assert_eq!(node.children()[2].bounds().height, expected_editor); + assert_eq!(measured.get(), expected_drawer); + assert_eq!(node.children()[4].bounds().y + STATUS_HEIGHT, host.height); + } + } + + #[test] + fn keyboard_resize_uses_effective_height_and_only_exact_open_drawer_shortcut() { + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Named, Physical}, + }; + for (open, modifiers, named, expected) in [ + (true, Modifiers::ALT, Named::PageUp, Some(140.)), + (true, Modifiers::ALT, Named::PageDown, Some(260.)), + (false, Modifiers::ALT, Named::PageDown, None), + ( + true, + Modifiers::CTRL | Modifiers::ALT, + Named::PageDown, + None, + ), + (true, Modifiers::ALT | Modifiers::SHIFT, Named::PageUp, None), + ] { + let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout( + text("Strip").into(), + text("Find").into(), + text("Editor").into(), + text("Drawer").into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: true, + drawer_height: open.then_some(1200.), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + let mut tree = Tree::new(view.as_widget()); + let host = Size::new(720., 540.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, host), + ); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(named), + modified_key: Key::Named(named), + physical_key: Physical::Code(Code::PageDown), + location: keyboard::Location::Standard, + modifiers, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let mut shell = iced::advanced::Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + iced::advanced::Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(host), + ); + assert_eq!(shell.is_event_captured(), expected.is_some()); + assert_eq!(messages.len(), usize::from(expected.is_some())); + if let Some(expected) = expected { + assert!( + matches!(messages[0], Message::TextWorkspace(Event::ResizeDrawer(context, actual)) if actual == expected && context.document == oa_core::Id::nil() && context.session == oa_core::Id::nil() && context.visit == oa_core::Id::nil()) + ); + } + } + } + + #[derive(Default)] + struct ScrollProbe { + scroll: Option<(usize, f32)>, + } + impl iced::advanced::renderer::Renderer for ScrollProbe { + fn start_layer(&mut self, _: iced::Rectangle) {} + fn end_layer(&mut self) {} + fn start_transformation(&mut self, _: iced::Transformation) {} + fn end_transformation(&mut self) {} + fn reset(&mut self, _: iced::Rectangle) {} + fn fill_quad(&mut self, _: iced::advanced::renderer::Quad, _: impl Into) { + } + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce( + Result, + ) + Send + + 'static, + ) { + iced::advanced::renderer::Renderer::allocate_image(&mut (), handle, callback); + } + } + impl iced::advanced::text::Renderer for ScrollProbe { + type Font = Font; + type Paragraph = iced::advanced::graphics::text::Paragraph; + type Editor = iced::advanced::graphics::text::Editor; + const ICON_FONT: Font = Font::DEFAULT; + const CHECKMARK_ICON: char = '0'; + const ARROW_DOWN_ICON: char = '0'; + const SCROLL_UP_ICON: char = '0'; + const SCROLL_DOWN_ICON: char = '0'; + const SCROLL_LEFT_ICON: char = '0'; + const SCROLL_RIGHT_ICON: char = '0'; + const ICED_LOGO: char = '0'; + fn default_font(&self) -> Font { + Font::DEFAULT + } + fn default_size(&self) -> iced::Pixels { + iced::Pixels(16.) + } + fn fill_paragraph( + &mut self, + _: &Self::Paragraph, + _: iced::Point, + _: iced::Color, + _: iced::Rectangle, + ) { + } + fn fill_editor( + &mut self, + editor: &Self::Editor, + _: iced::Point, + _: iced::Color, + _: iced::Rectangle, + ) { + let scroll = editor.buffer().scroll(); + self.scroll = Some((scroll.line, scroll.vertical)); + } + fn fill_text( + &mut self, + _: iced::advanced::text::Text, + _: iced::Point, + _: iced::Color, + _: iced::Rectangle, + ) { + } + } + + #[test] + fn highlighted_sensor_editor_retains_real_scrolled_buffer_across_panel_rebuilds() { + let source = (0..100) + .map(|index| format!("// 雨 {index} @assess\r\n")) + .collect::(); + let mut content = widget::text_editor::Content::::with_text(&source); + content.move_to(widget::text_editor::Cursor { + position: widget::text_editor::Position { line: 2, column: 6 }, + selection: Some(widget::text_editor::Position { line: 2, column: 3 }), + }); + let cursor = content.cursor(); + let mut tree = Tree::empty(); + let mut probe = ScrollProbe::default(); + let mut baseline_scroll = None; + let host = Size::new(720., 540.); + // First iteration sends a real wheel event and applies its editor action. + for (iteration, (find, drawer)) in [ + (false, false), + (false, false), + (true, false), + (true, true), + (true, true), + (false, true), + (false, false), + ] + .into_iter() + .enumerate() + { + let mut messages = Vec::new(); + { + let editor = widget::text_editor(&content) + .on_action(Message::SourceEdit) + .height(Fill) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .highlight_with::( + source.clone().into(), + crate::highlight::format, + ); + let editor = widget::sensor(editor) + .key(("救援/assess.arcadia".to_owned(), None::)) + .on_show(|_| Message::SourceViewport(None)) + .on_resize(|_| Message::SourceViewport(None)); + let mut view = layout( + text("Strip").into(), + text("Find").into(), + editor.into(), + text("Details").into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: find, + drawer_height: drawer.then_some(180.), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &probe, + &layout::Limits::new(Size::ZERO, host), + ); + let state = tree.children[2].children[0].state.downcast_mut::>(); + if iteration == 0 { + state.focus(); + } else { + assert!(state.is_focused()); + } + if iteration == 0 { + let mut shell = iced::advanced::Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &iced::Event::Mouse(iced::mouse::Event::WheelScrolled { + delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. }, + }), + iced::advanced::Layout::new(&node), + iced::mouse::Cursor::Available(iced::Point::new(100., 120.)), + &probe, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(host), + ); + assert!(shell.is_event_captured()); + } else { + view.as_widget().draw( + &tree, + &mut probe, + &style::theme(), + &iced::advanced::renderer::Style { + text_color: style::INK, + }, + iced::advanced::Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &iced::Rectangle::with_size(host), + ); + let scroll = probe.scroll.expect("actual highlighted editor was drawn"); + assert!( + scroll.0 > 0 || scroll.1 > 0., + "the fixture must actually scroll" + ); + if let Some(expected) = baseline_scroll { + assert_eq!(scroll, expected); + } else { + baseline_scroll = Some(scroll); + } + } + } + if iteration == 0 { + assert!(messages.iter().any(|message| matches!( + message, + Message::SourceEdit(widget::text_editor::Action::Scroll { .. }) + ))); + } + for message in messages { + if let Message::SourceEdit(action) = message { + content.perform(action); + } + } + assert_eq!(content.cursor(), cursor); + } + } +} + +#[cfg(all(test, debug_assertions))] +pub(crate) mod summary_visibility_tests; + +/// The same retained layout and Alt+PageDown publisher as the production view; +/// only its five content slots are inert for this CPU integration witness. +#[cfg(all(test, debug_assertions))] +pub(super) fn capture_drawer_resize(app: &App) -> Message { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Layout, Shell, layout as core_layout, widget::Tree}; + use iced::keyboard::{self, Key, Modifiers}; + let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout( + widget::Space::new().into(), + widget::Space::new().into(), + widget::Space::new().into(), + widget::Space::new().into(), + widget::Space::new().into(), + LayoutSettings { + drawer_context: app.text_drawer_context(), + find_open: false, + drawer_height: Some(app.text_workspace.drawer_height()), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + let mut tree = Tree::new(view.as_widget()); + let host = iced::Size::new(720., 700.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &core_layout::Limits::new(iced::Size::ZERO, host), + ); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(keyboard::key::Named::PageDown), + modified_key: Key::Named(keyboard::key::Named::PageDown), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::PageDown), + location: keyboard::Location::Standard, + modifiers: Modifiers::ALT, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(host), + ); + assert!(shell.is_event_captured()); + assert_eq!(messages.len(), 1); + let message = messages.remove(0); + assert!( + matches!(&message, Message::TextWorkspace(Event::ResizeDrawer(context, height)) + if *context == app.text_drawer_context() && *height > app.text_workspace.drawer_height()) + ); + message +} diff --git a/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs b/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs new file mode 100644 index 00000000..078dd6a0 --- /dev/null +++ b/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs @@ -0,0 +1,262 @@ +//! Shapes and draws the production summary, checking actual paragraph advances. +//! Native validation remains necessary for platform raster ink/fallback fonts. +use super::*; +use crate::source_navigation::query; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree}; +use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation}; +use std::sync::Arc; + +struct Cluster { + paragraph: String, + bytes: std::ops::Range, + bounds: Rectangle, + clip: Rectangle, +} +struct Recorder { + clips: Vec, + transforms: Vec, + clusters: Vec, +} +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) {} + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} +impl shaped::Renderer for Recorder { + type Font = Font; + type Paragraph = ::Paragraph; + type Editor = ::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::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::>() + .join("\n"); + let clip = self.clip(clip); + for run in buffer.layout_runs() { + for glyph in run.glyphs { + self.clusters.push(Cluster { + paragraph: content.clone(), + bytes: glyph.start..glyph.end, + bounds: Rectangle { + x: position.x + glyph.x + glyph.x_offset * glyph.font_size, + y: position.y + run.line_top, + width: glyph.w, + height: run.line_height, + } * self.transform(), + clip, + }); + } + } + } + fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {} + fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} +} + +const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"]; + +fn draw_and_check_flags( + content: Element<'_, Message, iced::Theme, Recorder>, + width: f32, + flags: &[&str], +) -> Vec { + let viewport = Rectangle::with_size(Size::new(width, 24.)); + let mut renderer = Recorder { + clips: vec![viewport], + transforms: vec![Transformation::IDENTITY], + clusters: Vec::new(), + }; + let mut view: Element<'_, Message, iced::Theme, Recorder> = + container(content).width(Fill).height(24).clip(true).into(); + let mut tree = Tree::new(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, viewport.size()), + ); + assert_eq!(node.size(), viewport.size()); + view.as_widget().draw( + &tree, + &mut renderer, + &iced::Theme::Light, + &renderer::Style::default(), + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + let mut failures = Vec::new(); + for &flag in flags { + for byte in 0..flag.len() { + let matching: Vec<_> = renderer + .clusters + .iter() + .filter(|cluster| { + cluster + .paragraph + .find(flag) + .is_some_and(|start| cluster.bytes.contains(&(start + byte))) + }) + .collect(); + if matching.is_empty() { + failures.push(format!("missing shaped flag byte: {flag}[{byte}]")); + } + for cluster in matching { + let b = cluster.bounds; + let c = cluster.clip; + if !(b.x >= c.x - 0.5 + && b.y >= c.y - 0.5 + && b.x + b.width <= c.x + c.width + 0.5 + && b.y + b.height <= c.y + c.height + 0.5) + { + failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}")); + } + } + } + } + failures +} + +fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> { + text(value) + .size(12) + .wrapping(widget::text::Wrapping::None) + .into() +} + +#[test] +fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() { + let mut app = App::with_project(oa_core::Project::empty()); + let old_key = App::with_project(oa_core::Project::empty()).source_key(); + assert_ne!(old_key, app.source_key()); + for alias in ["雨".repeat(32), "W".repeat(32)] { + // Unicode aliases are presentation stress: the current lexer admits + // ASCII identifiers only. Six-digit totals are within the query's + // 250,000-record cap; this does not build that many source records. + let old_value = format!( + "@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified" + ); + app.source_symbols.results = Some(Arc::new(query::Results { + stamp: query::QueryStamp { + snapshot: old_key, + serial: 1, + }, + action: query::Action::References, + origin_path: app.source_workspace.active.clone(), + origin_offset: 0, + origin: None, + alias: Some(alias.clone()), + scope: query::Scope { + closure_complete: false, + applied: false, + validated: false, + included_files: 28, + detached_files: 1, + unverified_files: 1, + issues: Vec::new(), + }, + disposition: query::Disposition::LexicalReferences, + definitions: Vec::new(), + references: Vec::new(), + total_definitions: 100_000, + total_references: 100_000, + omitted_definitions: 100_000, + omitted_references: 100_000, + })); + for width in [840., 720.] { + let positive = draw_and_check_flags( + reference_summary(&app, app.source_results_current()), + width, + &FLAGS, + ); + assert!( + positive.is_empty(), + "reserved flags, width {width}, alias {alias:?}: {positive:?}" + ); + // Observe the exact old presentation without predicting font widths. + // This is emitted under --nocapture, whether it clips or fits. + let old = draw_and_check_flags(old_summary(old_value.clone()), width, &FLAGS); + println!( + "old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}", + old.len(), + old.first() + ); + } + } + // Deliberate evaluator stress, beyond the production 32-character subject + // bound: require the same evaluator to reject a clipped old-style paragraph. + // This is not evidence of a production/native overflow at 840 pixels. + let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · ")); + let rejected = draw_and_check_flags(old_summary(stress), 720., &FLAGS); + assert!( + !rejected.is_empty(), + "negative control must detect missing/clipped warning text" + ); +} + +pub(crate) fn rendered_stale(app: &App) -> bool { + draw_and_check_flags( + reference_summary(app, app.source_results_current()), + 720., + &["Stale"], + ) + .is_empty() +} diff --git a/crates/oa-server/tests/http/port_anchors.rs b/crates/oa-server/tests/http/port_anchors.rs index 33c42456..9d9b017a 100644 --- a/crates/oa-server/tests/http/port_anchors.rs +++ b/crates/oa-server/tests/http/port_anchors.rs @@ -75,6 +75,164 @@ fn assert_pair(current: &StoredProject) { assert_eq!(compiled.bundle, current.source_project); } +#[tokio::test] +async fn explicit_empty_port_anchors_survive_http_move_and_restart_but_export_compactly() { + fn replace_once(source: &mut String, before: &str, after: &str) { + assert_eq!( + source.matches(before).count(), + 1, + "ambiguous or absent anchor: {before}" + ); + let replacement = source.replacen(before, after, 1); + assert_ne!(*source, replacement, "fixture mutation must change source"); + *source = replacement; + } + + async fn assert_exports(server: &TestServer, current: &StoredProject, node: Id) -> String { + let response = server + .client + .get(server.url(&format!("/projects/{}/source-project", current.resource_id))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["etag"], current.etag()); + assert_eq!( + response.json::().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(¤t.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(¤t); + let canonical = assert_exports(&server, ¤t, 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(¤t).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(); diff --git a/crates/vendor/iced_widget/.cargo_vcs_info.json b/crates/vendor/iced_widget/.cargo_vcs_info.json new file mode 100644 index 00000000..3afb88d3 --- /dev/null +++ b/crates/vendor/iced_widget/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "54abf81d13fec06d4d9ac754b03ce2c3313e8a1f" + }, + "path_in_vcs": "widget" +} \ No newline at end of file diff --git a/crates/vendor/iced_widget/ARCADIA-PATCH.md b/crates/vendor/iced_widget/ARCADIA-PATCH.md new file mode 100644 index 00000000..e4c1ef0c --- /dev/null +++ b/crates/vendor/iced_widget/ARCADIA-PATCH.md @@ -0,0 +1,31 @@ +# Pinned Iced widget patch + +Upstream crate: iced_widget 0.14.2, MIT. Its registry VCS record identifies +`iced-rs/iced` commit `54abf81d13fec06d4d9ac754b03ce2c3313e8a1f`, path `widget`. +The accompanying LICENSE was obtained from that exact upstream commit: +https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c3313e8a1f/LICENSE +SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`. + +All copied src/assets and Cargo manifests retain their upstream bytes except +`src/text_editor.rs`, which adds one documented read-only `Content::with_editor` +callback. Original file SHA-256: +`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`. +The callback borrows the existing renderer editor and releases the borrow when +the closure returns. It adds no mutable editor access or event behavior. + +ARCADIA uses this seam to project source diagnostics onto the actual painted +editor layout. It must not clone/rebuild Content or move user selection for +measurement. The isolated Windows CPU experiment verified the exact painted +editor identity and geometry plus retained Word/Line selection and scroll; +application integration/native validation are separately required. + +UPSTREAM-MANIFEST.json records all files from the local pinned registry source, +including registry bookkeeping and the upstream crate lockfile not copied here. +Those omitted files are not package inputs. The workspace root Cargo.lock and +local patch path identify the resolved dependency; this patched package is not +the unmodified registry checksum. Keep LICENSE and this provenance in source +captures and generated dependency notices. + +On upgrades compare the pristine upstream module and replay only this accessor. +Recheck actual editor layout, selection/input continuity and annotation tests. +Remove this patch if upstream provides equivalent read-only layout access. diff --git a/crates/vendor/iced_widget/Cargo.toml b/crates/vendor/iced_widget/Cargo.toml new file mode 100644 index 00000000..e7c5b0db --- /dev/null +++ b/crates/vendor/iced_widget/Cargo.toml @@ -0,0 +1,126 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "iced_widget" +version = "0.14.2" +authors = ["Héctor Ramón Jiménez "] +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" diff --git a/crates/vendor/iced_widget/Cargo.toml.orig b/crates/vendor/iced_widget/Cargo.toml.orig new file mode 100644 index 00000000..aa987c2c --- /dev/null +++ b/crates/vendor/iced_widget/Cargo.toml.orig @@ -0,0 +1,51 @@ +[package] +name = "iced_widget" +description = "The built-in widgets for iced" +version = "0.14.2" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +categories.workspace = true +keywords.workspace = true + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = ["--cfg", "docsrs"] +all-features = true + +[features] +lazy = ["ouroboros"] +image = ["iced_renderer/image"] +svg = ["iced_renderer/svg"] +canvas = ["iced_renderer/geometry"] +qr_code = ["canvas", "dep:qrcode"] +wgpu = ["iced_renderer/wgpu-bare"] +markdown = ["dep:pulldown-cmark"] +highlighter = ["dep:iced_highlighter"] +advanced = [] +crisp = [] + +[dependencies] +iced_renderer.workspace = true + +num-traits.workspace = true +log.workspace = true +rustc-hash.workspace = true +thiserror.workspace = true +unicode-segmentation.workspace = true + +ouroboros.workspace = true +ouroboros.optional = true + +qrcode.workspace = true +qrcode.optional = true + +pulldown-cmark.workspace = true +pulldown-cmark.optional = true + +iced_highlighter.workspace = true +iced_highlighter.optional = true diff --git a/crates/vendor/iced_widget/LICENSE b/crates/vendor/iced_widget/LICENSE new file mode 100644 index 00000000..f1148e26 --- /dev/null +++ b/crates/vendor/iced_widget/LICENSE @@ -0,0 +1,18 @@ +Copyright 2019 Héctor Ramón, Iced contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/iced_widget/UPSTREAM-MANIFEST.json b/crates/vendor/iced_widget/UPSTREAM-MANIFEST.json new file mode 100644 index 00000000..5e268a0e --- /dev/null +++ b/crates/vendor/iced_widget/UPSTREAM-MANIFEST.json @@ -0,0 +1,352 @@ +[ + { + "path": ".cargo-ok", + "bytes": 7, + "sha256": "afbf9d0f3560b0fd7795e81c42a0a79ee6b6fc67e064f77826aee642cad28d91" + }, + { + "path": ".cargo_vcs_info.json", + "bytes": 100, + "sha256": "58c111ee6197295ae381d021ab7afd7ac38b02acce90d0ff0f82ae2e1e5f864c" + }, + { + "path": "assets/iced-logo.svg", + "bytes": 1892, + "sha256": "8ac9e30de1b466b3d594c77c984299b1a229f6f0f010e6804139a2f41ea8345e" + }, + { + "path": "Cargo.lock", + "bytes": 56700, + "sha256": "89fe393e9bbc1c6dc94796d764dfb3f864adb400607b4508b6138eb3205cce37" + }, + { + "path": "Cargo.toml", + "bytes": 2622, + "sha256": "e8e6b9a72fc0f97c84b8fb2703fe6cdccd738c6c01d2fadf018a012182de93b5" + }, + { + "path": "Cargo.toml.orig", + "bytes": 1109, + "sha256": "f9bed37f4aa1edebea47824c1f9678411d410a1546401a14ce6f8b4108c96448" + }, + { + "path": "src/action.rs", + "bytes": 2507, + "sha256": "feb91a386073280d76eaf37dbb8e7380e5a6bf9b8003110107b32bb8f577a239" + }, + { + "path": "src/button.rs", + "bytes": 21150, + "sha256": "a8019fee3ad691fb2395afcdf302bec5f9e1cc592552aff4d8c8bbd1c6149f5a" + }, + { + "path": "src/canvas/program.rs", + "bytes": 3099, + "sha256": "637bbc6d10a623f355c6d44af1162a10331861806cec094e2a8f2a97313acab9" + }, + { + "path": "src/canvas.rs", + "bytes": 9637, + "sha256": "1fecfd50d593c5ff9bc9317c9f1624d0c22eddc7cf39a795684bf70eb5954acd" + }, + { + "path": "src/checkbox.rs", + "bytes": 20973, + "sha256": "4409c11d6473baf4baefafbb3ed253cd9353f04136cac6009422a876a84ec6b1" + }, + { + "path": "src/column.rs", + "bytes": 17490, + "sha256": "f880d15ebdb33e2f6ebb6067bb0b124c8fafdc73a6e5112a6f5b5e012fddadf4" + }, + { + "path": "src/combo_box.rs", + "bytes": 30694, + "sha256": "973901d05003f684be1d13da5adc88ef7e030dc31db07c1bb0717cf9210d2391" + }, + { + "path": "src/container.rs", + "bytes": 18126, + "sha256": "f8162a4e1c25c602070da482ecccfd5e62f456ffe1caa5394a11243d339f6114" + }, + { + "path": "src/float.rs", + "bytes": 12092, + "sha256": "9bc2d313052c97338dcdec2a5be7e0301b6261fe7975b511050fe4abeeb1c571" + }, + { + "path": "src/grid.rs", + "bytes": 11862, + "sha256": "01e4deea4638fba19e3859d7ae27bd8090bca3fcee6f31c48bf5ad9a19f5c151" + }, + { + "path": "src/helpers.rs", + "bytes": 60874, + "sha256": "3989cad7e901ff8171a16024d93ec40cb32e7859c2e76a98359f5f230a2fabcb" + }, + { + "path": "src/image/viewer.rs", + "bytes": 14066, + "sha256": "ce372e8ba97248cea915abd52046020c0f5c318efc2af1d7f4d74958b14629bf" + }, + { + "path": "src/image.rs", + "bytes": 12361, + "sha256": "dab4c761c846ba2006ba5298c9b3573b0a2787272b2f74376cde6c35ae7f3694" + }, + { + "path": "src/keyed/column.rs", + "bytes": 10710, + "sha256": "09adcae868d561443e8d8b6f3cdfefd6831d8e94adf2b22c551c85b9669eb620" + }, + { + "path": "src/keyed.rs", + "bytes": 2840, + "sha256": "f1e572a13d0717816a6d4467d14f07fa613956a6bf9a2b234d79789b0bcb1d57" + }, + { + "path": "src/lazy/cache.rs", + "bytes": 377, + "sha256": "59445853fb59941d73904689e5ee34bc6e9f7159c58bbea0cac8ef87f69a1b10" + }, + { + "path": "src/lazy/component.rs", + "bytes": 20440, + "sha256": "05e4e793f3462e5f164ef1191a5cf2c6daf32d410aeebbe1bb4e951fb04fdef7" + }, + { + "path": "src/lazy/helpers.rs", + "bytes": 1292, + "sha256": "d6bab15685469619bc3833afacc1df22112adacbdf944d6dbb5ea1b9e5f9d993" + }, + { + "path": "src/lazy.rs", + "bytes": 11530, + "sha256": "8c127fcd044cf887564856ce3a38ecf41ff3ff24d832d72095ec4e92f9907673" + }, + { + "path": "src/lib.rs", + "bytes": 2933, + "sha256": "4da7f1a83cd8edc30bd9983e59ba3537f44a475814d0b9c80dcf7e97873badb4" + }, + { + "path": "src/markdown.rs", + "bytes": 50899, + "sha256": "5269582001d26d79f9de4e6ee60ebed587adf552f6f91c8ffcd757782319d161" + }, + { + "path": "src/mouse_area.rs", + "bytes": 12666, + "sha256": "01686e65f76a4b89356ec6e5900b4411c856b15a56236ed3f6ef410ec3a4983d" + }, + { + "path": "src/overlay/menu.rs", + "bytes": 19043, + "sha256": "89719b05b75b4b1382de49e1220cab0034b155a24d744509ad928a25d69cca57" + }, + { + "path": "src/overlay.rs", + "bytes": 72, + "sha256": "104d339ba61626ebf0877f5c942e9f67b3db6261d6c9ae5e4805859508e1bc49" + }, + { + "path": "src/pane_grid/axis.rs", + "bytes": 7860, + "sha256": "4e7a842bc60bee64ee86c4d512c0ac034e4c6bf16b1fe0777c16b8a0b5b28751" + }, + { + "path": "src/pane_grid/configuration.rs", + "bytes": 609, + "sha256": "0d70a26e2fcafc1f1403e43f6c0790698940c92a0966d4ea4acec216778d65cd" + }, + { + "path": "src/pane_grid/content.rs", + "bytes": 11886, + "sha256": "13ec6f9517e0cbd7d2a311ef57b963eff2c5fe6f05264cca29e54d14a0ea8151" + }, + { + "path": "src/pane_grid/controls.rs", + "bytes": 1591, + "sha256": "c7eeffce253b8aa0e7853baaf33ab3e926261df7e6b809a566aa17a4e779d560" + }, + { + "path": "src/pane_grid/direction.rs", + "bytes": 186, + "sha256": "f79259afb26fb38f4af7efdaea5fc6527b5426fe195ef89f72d16405d0bc3076" + }, + { + "path": "src/pane_grid/draggable.rs", + "bytes": 290, + "sha256": "3688ee1a543a281c036d2f4f156a3c529263219b50e3945ef50fa4bb63b02ae3" + }, + { + "path": "src/pane_grid/node.rs", + "bytes": 10452, + "sha256": "bcda8a561581849163a3bb75f34d310d6463ff58ea6660e9fa33c8ea22d40c9f" + }, + { + "path": "src/pane_grid/pane.rs", + "bytes": 209, + "sha256": "2305c36971b405368a99394ad6b3cd0cf21638d9933f8cbd445fd764a257a4df" + }, + { + "path": "src/pane_grid/split.rs", + "bytes": 221, + "sha256": "8be472fc48c37055bbb62395839c599559abc3d70cb7806892f869bd95e90804" + }, + { + "path": "src/pane_grid/state.rs", + "bytes": 14109, + "sha256": "a13125af64ea6da1bcb5a13469f4ffdd51106776b27871e10ce3ac037d865b63" + }, + { + "path": "src/pane_grid/title_bar.rs", + "bytes": 20398, + "sha256": "7556b48c85811d64e3c11c4ce901c579568f4ec056ff5f1bd5e38066b2f059ce" + }, + { + "path": "src/pane_grid.rs", + "bytes": 42574, + "sha256": "4eb5ae3be82237f5193b550d407c1a5a9f099a4d554442dbe8f51b0b72edb213" + }, + { + "path": "src/pick_list.rs", + "bytes": 27489, + "sha256": "449fbbc5342c2bed227ec2587111ab43dcecb7382001fbcba2fab2651e54b33b" + }, + { + "path": "src/pin.rs", + "bytes": 7207, + "sha256": "f0b0ab2089300c9ceab8f8f3c75cd96409c7a84f3fa26d68645aab3f92247426" + }, + { + "path": "src/progress_bar.rs", + "bytes": 8776, + "sha256": "38e9bb585a9b764ff6cb947e17d65259a6a32cf3a69b23844e4c28e6c5a1c3ba" + }, + { + "path": "src/qr_code.rs", + "bytes": 12261, + "sha256": "712482a35eab47e0559fafd5452a2b934c5fbc5ae07c14c0c71781713b4dbfcc" + }, + { + "path": "src/radio.rs", + "bytes": 15482, + "sha256": "7b0f42744416e07f8af9409c5bae997b07a0653492ccc2e71911bdeed9c747b2" + }, + { + "path": "src/responsive.rs", + "bytes": 5358, + "sha256": "2a3ff68573825a1407e3be27adbfb4851c7aae7cd100c7a0b670911bc3a08393" + }, + { + "path": "src/row.rs", + "bytes": 16919, + "sha256": "2566e8868bced1a6b7d455c9f1ab61c7025772b27bf843ee806d5c7806fcd44b" + }, + { + "path": "src/rule.rs", + "bytes": 8599, + "sha256": "b7b23722d314864d712e21c8ef260d2b747d0ac795bb3263a0a762c38467d6b9" + }, + { + "path": "src/scrollable.rs", + "bytes": 80594, + "sha256": "a176ea537bbe717550fe4dc17af31c801d9ec118edbcc44aab59ea1a78d1a7a2" + }, + { + "path": "src/sensor.rs", + "bytes": 12155, + "sha256": "cb95d1220fa5663b7a148d6b159081da2200debe6fea982a04f48e27dd072973" + }, + { + "path": "src/shader/program.rs", + "bytes": 1761, + "sha256": "4ce3711b2899b1c1d53544ff0c1f823e108542d356d5e374cb7b149c776d6918" + }, + { + "path": "src/shader.rs", + "bytes": 5094, + "sha256": "8753516e6a6b422a76632d07c716e047ff9b5e8f32bf9a9463c612c292306382" + }, + { + "path": "src/slider.rs", + "bytes": 21695, + "sha256": "778d6a4c37b334cba62ada5f4958e5909a91404961fe5c1104acd9dad12a18d5" + }, + { + "path": "src/space.rs", + "bytes": 2436, + "sha256": "40839bf1dfa93e02c9e3d7e2ab5bb41b328b1fe863846f6f1feca11d9885453b" + }, + { + "path": "src/stack.rs", + "bytes": 12078, + "sha256": "b12776ae1dd46ba95ca0ca52f6a095be1e0121c51f3180ad7cc202cd55764594" + }, + { + "path": "src/svg.rs", + "bytes": 9618, + "sha256": "a8167836a19806f4c2cc633a265953b8c4348f279082d7577b69f817e3166aeb" + }, + { + "path": "src/table.rs", + "bytes": 20855, + "sha256": "373f600db363090412e6bfc9c338bd6bc9516e78f7a43fb00f20d5ed21eb625c" + }, + { + "path": "src/text/rich.rs", + "bytes": 16952, + "sha256": "7deababcbe5f0294b605822fe2651eb0fee6e9c1bee37883dbc570481df6a0b9" + }, + { + "path": "src/text.rs", + "bytes": 902, + "sha256": "a7d125fc7d07ba51e05f86ca97f4ffad5db37ef2e6d4320bbcd8475d99d51ed3" + }, + { + "path": "src/text_editor.rs", + "bytes": 48281, + "sha256": "cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1" + }, + { + "path": "src/text_input/cursor.rs", + "bytes": 5540, + "sha256": "eeb9241a25110583fd2e169da47834aeb39fd55335ea3b77aa3d1e43016c28d8" + }, + { + "path": "src/text_input/editor.rs", + "bytes": 1960, + "sha256": "cd0e0168e9de1ad2599120a4dda8a5a373813a4ffc3a3c035929d18144544408" + }, + { + "path": "src/text_input/value.rs", + "bytes": 4403, + "sha256": "03fba71d34f2c31df15431ac7e22f854b655336f86ef854ae34cc1e0bfbcefdd" + }, + { + "path": "src/text_input.rs", + "bytes": 61293, + "sha256": "7cd64b0f79c6000f2985cc290642698a814be5dcd7b7a56ae463b13ffe5c3b47" + }, + { + "path": "src/themer.rs", + "bytes": 8557, + "sha256": "4b87e866b8f2a4420f30674ad64c605d24e3004f867bf95a1210b5338ed7a81e" + }, + { + "path": "src/toggler.rs", + "bytes": 18223, + "sha256": "70f1ef0ee8e2b6c0d7b93095eab1e615783bb81d945b4ff066af025dde8f5dcb" + }, + { + "path": "src/tooltip.rs", + "bytes": 17259, + "sha256": "7fcaa3899ad1a946bb1a51d34d344497005aaccdf825f510e98105d1bc40bc63" + }, + { + "path": "src/vertical_slider.rs", + "bytes": 17741, + "sha256": "75777345ced463b4a3b059fbe6c59efc952169987154b0597d87ce4da2076e3b" + } +] diff --git a/crates/vendor/iced_widget/assets/iced-logo.svg b/crates/vendor/iced_widget/assets/iced-logo.svg new file mode 100644 index 00000000..459b7fbb --- /dev/null +++ b/crates/vendor/iced_widget/assets/iced-logo.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/vendor/iced_widget/src/action.rs b/crates/vendor/iced_widget/src/action.rs new file mode 100644 index 00000000..cc31e76a --- /dev/null +++ b/crates/vendor/iced_widget/src/action.rs @@ -0,0 +1,85 @@ +use crate::core::event; +use crate::core::time::Instant; +use crate::core::window; + +/// A runtime action that can be performed by some widgets. +#[derive(Debug, Clone)] +pub struct Action { + message_to_publish: Option, + redraw_request: window::RedrawRequest, + event_status: event::Status, +} + +impl Action { + 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, window::RedrawRequest, event::Status) { + ( + self.message_to_publish, + self.redraw_request, + self.event_status, + ) + } +} diff --git a/crates/vendor/iced_widget/src/button.rs b/crates/vendor/iced_widget/src/button.rs new file mode 100644 index 00000000..3dffb49f --- /dev/null +++ b/crates/vendor/iced_widget/src/button.rs @@ -0,0 +1,752 @@ +//! Buttons allow your users to perform actions by pressing them. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } } +//! # pub type State = (); +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! use iced::widget::button; +//! +//! #[derive(Clone)] +//! enum Message { +//! ButtonPressed, +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! button("Press me!").on_press(Message::ButtonPressed).into() +//! } +//! ``` +use crate::core::border::{self, Border}; +use crate::core::layout; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::theme::palette; +use crate::core::touch; +use crate::core::widget::Operation; +use crate::core::widget::tree::{self, Tree}; +use crate::core::window; +use crate::core::{ + Background, Clipboard, Color, Element, Event, Layout, Length, Padding, + Rectangle, Shadow, Shell, Size, Theme, Vector, Widget, +}; + +/// A generic widget that produces a message when pressed. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::button; +/// +/// #[derive(Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// button("Press me!").on_press(Message::ButtonPressed).into() +/// } +/// ``` +/// +/// If a [`Button::on_press`] handler is not set, the resulting [`Button`] will +/// be disabled: +/// +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::button; +/// +/// #[derive(Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// button("I am disabled!").into() +/// } +/// ``` +pub struct Button<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +where + Renderer: crate::core::Renderer, + Theme: Catalog, +{ + content: Element<'a, Message, Theme, Renderer>, + on_press: Option>, + width: Length, + height: Length, + padding: Padding, + clip: bool, + class: Theme::Class<'a>, + status: Option, +} + +enum OnPress<'a, Message> { + Direct(Message), + Closure(Box Message + 'a>), +} + +impl 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>, + ) -> 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) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Button`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Padding`] of the [`Button`]. + pub fn 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) -> 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>, + { + 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>) -> Self { + self.class = class.into(); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct State { + is_pressed: bool, +} + +impl<'a, Message, Theme, Renderer> Widget + 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::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + 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.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::(); + + 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.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::(); + + 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> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + 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, + /// 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) -> 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>`. +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 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 + } +} diff --git a/crates/vendor/iced_widget/src/canvas.rs b/crates/vendor/iced_widget/src/canvas.rs new file mode 100644 index 00000000..b1b6ac13 --- /dev/null +++ b/crates/vendor/iced_widget/src/canvas.rs @@ -0,0 +1,327 @@ +//! Canvases can be leveraged to draw interactive 2D graphics. +//! +//! # Example: Drawing a Simple Circle +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +//! # pub type State = (); +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! # +//! use iced::mouse; +//! use iced::widget::canvas; +//! use iced::{Color, Rectangle, Renderer, Theme}; +//! +//! // First, we define the data we need for drawing +//! #[derive(Debug)] +//! struct Circle { +//! radius: f32, +//! } +//! +//! // Then, we implement the `Program` trait +//! impl canvas::Program for Circle { +//! // No internal state +//! type State = (); +//! +//! fn draw( +//! &self, +//! _state: &(), +//! renderer: &Renderer, +//! _theme: &Theme, +//! bounds: Rectangle, +//! _cursor: mouse::Cursor +//! ) -> Vec { +//! // 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 = geometry::Cache; + +/// The geometry supported by a renderer. +pub type Geometry = + ::Geometry; + +/// The frame supported by a renderer. +pub type Frame = geometry::Frame; + +/// 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 canvas::Program for Circle { +/// // No internal state +/// type State = (); +/// +/// fn draw( +/// &self, +/// _state: &(), +/// renderer: &Renderer, +/// _theme: &Theme, +/// bounds: Rectangle, +/// _cursor: mouse::Cursor +/// ) -> Vec { +/// // 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 +where + Renderer: geometry::Renderer, + P: Program, +{ + width: Length, + height: Length, + program: P, + message_: PhantomData, + theme_: PhantomData, + renderer_: PhantomData, + last_mouse_interaction: Option, +} + +impl Canvas +where + P: Program, + 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) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Canvas`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } +} + +impl Widget + for Canvas +where + Renderer: geometry::Renderer, + P: Program, +{ + fn tag(&self) -> tree::Tag { + struct Tag(T); + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(P::State::default()) + } + + fn size(&self) -> Size { + 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::(); + 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::(); + + 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::(); + + 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> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: 'a + geometry::Renderer, + P: 'a + Program, +{ + fn from( + canvas: Canvas, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(canvas) + } +} diff --git a/crates/vendor/iced_widget/src/canvas/program.rs b/crates/vendor/iced_widget/src/canvas/program.rs new file mode 100644 index 00000000..9644f676 --- /dev/null +++ b/crates/vendor/iced_widget/src/canvas/program.rs @@ -0,0 +1,111 @@ +use crate::Action; +use crate::canvas::mouse; +use crate::canvas::{Event, Geometry}; +use crate::core::Rectangle; +use crate::graphics::geometry; + +/// The state and logic of a [`Canvas`]. +/// +/// A [`Program`] can mutate internal state and produce messages for an +/// application. +/// +/// [`Canvas`]: crate::Canvas +pub trait Program +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> { + 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>; + + /// 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 Program for &T +where + Renderer: geometry::Renderer, + T: Program, +{ + type State = T::State; + + fn update( + &self, + state: &mut Self::State, + event: &Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + T::update(self, state, event, bounds, cursor) + } + + fn draw( + &self, + state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec> { + 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) + } +} diff --git a/crates/vendor/iced_widget/src/checkbox.rs b/crates/vendor/iced_widget/src/checkbox.rs new file mode 100644 index 00000000..6edab836 --- /dev/null +++ b/crates/vendor/iced_widget/src/checkbox.rs @@ -0,0 +1,707 @@ +//! Checkboxes can be used to let users make binary choices. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! # +//! use iced::widget::checkbox; +//! +//! struct State { +//! is_checked: bool, +//! } +//! +//! enum Message { +//! CheckboxToggled(bool), +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! checkbox(state.is_checked) +//! .label("Toggle me!") +//! .on_toggle(Message::CheckboxToggled) +//! .into() +//! } +//! +//! fn update(state: &mut State, message: Message) { +//! match message { +//! Message::CheckboxToggled(is_checked) => { +//! state.is_checked = is_checked; +//! } +//! } +//! } +//! ``` +//! ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true) +use crate::core::alignment; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text; +use crate::core::theme::palette; +use crate::core::touch; +use crate::core::widget; +use crate::core::widget::tree::{self, Tree}; +use crate::core::window; +use crate::core::{ + Background, Border, Clipboard, Color, Element, Event, Layout, Length, + Pixels, Rectangle, Shell, Size, Theme, Widget, +}; + +/// A box that can be checked. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// # +/// use iced::widget::checkbox; +/// +/// struct State { +/// is_checked: bool, +/// } +/// +/// enum Message { +/// CheckboxToggled(bool), +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// checkbox(state.is_checked) +/// .label("Toggle me!") +/// .on_toggle(Message::CheckboxToggled) +/// .into() +/// } +/// +/// fn update(state: &mut State, message: Message) { +/// match message { +/// Message::CheckboxToggled(is_checked) => { +/// state.is_checked = is_checked; +/// } +/// } +/// } +/// ``` +/// ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true) +pub struct Checkbox< + 'a, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Renderer: text::Renderer, + Theme: Catalog, +{ + is_checked: bool, + on_toggle: Option Message + 'a>>, + label: Option>, + width: Length, + size: f32, + spacing: f32, + text_size: Option, + text_line_height: text::LineHeight, + text_shaping: text::Shaping, + text_wrapping: text::Wrapping, + font: Option, + icon: Icon, + class: Theme::Class<'a>, + last_status: Option, +} + +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(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(mut self, f: Option) -> 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) -> Self { + self.size = size.into().0; + self + } + + /// Sets the width of the [`Checkbox`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the spacing between the [`Checkbox`] and the text. + pub fn spacing(mut self, spacing: impl Into) -> Self { + self.spacing = spacing.into().0; + self + } + + /// Sets the text size of the [`Checkbox`]. + pub fn text_size(mut self, text_size: impl Into) -> 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, + ) -> 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) -> Self { + self.font = Some(font.into()); + self + } + + /// Sets the [`Icon`] of the [`Checkbox`]. + pub fn icon(mut self, icon: Icon) -> 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>, + { + 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>) -> Self { + self.class = class.into(); + self + } +} + +impl Widget + for Checkbox<'_, Message, Theme, Renderer> +where + Renderer: text::Renderer, + Theme: Catalog, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(widget::text::State::::default()) + } + + fn size(&self) -> Size { + 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::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 = + 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> + 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 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, + /// 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, +} + +/// 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 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, + } +} diff --git a/crates/vendor/iced_widget/src/column.rs b/crates/vendor/iced_widget/src/column.rs new file mode 100644 index 00000000..0db0c74c --- /dev/null +++ b/crates/vendor/iced_widget/src/column.rs @@ -0,0 +1,617 @@ +//! Distribute content vertically. +use crate::core::alignment::{self, Alignment}; +use crate::core::layout; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::widget::{Operation, Tree}; +use crate::core::{ + Clipboard, Element, Event, Layout, Length, Padding, Pixels, Rectangle, + Shell, Size, Vector, Widget, +}; + +/// A container that distributes its contents vertically. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::{button, column}; +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// // ... +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// column![ +/// "I am on top!", +/// button("I am in the center!"), +/// "I am below.", +/// ].into() +/// } +/// ``` +pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +{ + spacing: f32, + padding: Padding, + width: Length, + height: Length, + max_width: f32, + align: Alignment, + clip: bool, + children: Vec>, +} + +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>, + ) -> 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>, + ) -> 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) -> Self { + self.spacing = amount.into().0; + self + } + + /// Sets the [`Padding`] of the [`Column`]. + pub fn 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) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Column`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the maximum width of the [`Column`]. + pub fn max_width(mut self, max_width: impl Into) -> 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) -> 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>, + ) -> 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>, + ) -> 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 Default for Column<'_, Message, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +impl<'a, Message, Theme, Renderer: crate::core::Renderer> + FromIterator> + for Column<'a, Message, Theme, Renderer> +{ + fn from_iter< + T: IntoIterator>, + >( + iter: T, + ) -> Self { + Self::with_children(iter) + } +} + +impl Widget + for Column<'_, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn children(&self) -> Vec { + self.children.iter().map(Tree::new).collect() + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&self.children); + } + + fn size(&self) -> Size { + 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::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + 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, + align_y: alignment::Vertical, +} + +impl Wrapping<'_, Message, Theme, Renderer> { + /// Sets the horizontal spacing _between_ columns. + pub fn horizontal_spacing(mut self, amount: impl Into) -> 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) -> Self { + self.align_y = align_y.into(); + self + } +} + +impl Widget + for Wrapping<'_, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn children(&self) -> Vec { + self.column.children() + } + + fn diff(&self, tree: &mut Tree) { + self.column.diff(tree); + } + + fn size(&self) -> Size { + 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 = 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, + column_width: f32, + children: &mut Vec| { + 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> { + self.column + .overlay(tree, layout, renderer, viewport, translation) + } +} + +impl<'a, Message, Theme, Renderer> From> + 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) + } +} diff --git a/crates/vendor/iced_widget/src/combo_box.rs b/crates/vendor/iced_widget/src/combo_box.rs new file mode 100644 index 00000000..775702cd --- /dev/null +++ b/crates/vendor/iced_widget/src/combo_box.rs @@ -0,0 +1,1013 @@ +//! Combo boxes display a dropdown list of searchable and selectable options. +//! +//! # 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::combo_box; +//! +//! struct State { +//! fruits: combo_box::State, +//! favorite: Option, +//! } +//! +//! #[derive(Debug, Clone)] +//! enum Fruit { +//! Apple, +//! Orange, +//! Strawberry, +//! Tomato, +//! } +//! +//! #[derive(Debug, Clone)] +//! enum Message { +//! FruitSelected(Fruit), +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! combo_box( +//! &state.fruits, +//! "Select your favorite fruit...", +//! state.favorite.as_ref(), +//! Message::FruitSelected +//! ) +//! .into() +//! } +//! +//! fn update(state: &mut State, message: Message) { +//! match message { +//! Message::FruitSelected(fruit) => { +//! state.favorite = Some(fruit); +//! } +//! } +//! } +//! +//! impl std::fmt::Display for Fruit { +//! fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +//! f.write_str(match self { +//! Self::Apple => "Apple", +//! Self::Orange => "Orange", +//! Self::Strawberry => "Strawberry", +//! Self::Tomato => "Tomato", +//! }) +//! } +//! } +//! ``` +use crate::core::keyboard; +use crate::core::keyboard::key; +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::text; +use crate::core::time::Instant; +use crate::core::widget::{self, Widget}; +use crate::core::{ + Clipboard, Element, Event, Length, Padding, Pixels, Rectangle, Shell, Size, + Theme, Vector, +}; +use crate::overlay::menu; +use crate::text::LineHeight; +use crate::text_input::{self, TextInput}; + +use std::cell::RefCell; +use std::fmt::Display; + +/// A widget for searching and selecting a single value from a list of options. +/// +/// # 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::combo_box; +/// +/// struct State { +/// fruits: combo_box::State, +/// favorite: Option, +/// } +/// +/// #[derive(Debug, Clone)] +/// enum Fruit { +/// Apple, +/// Orange, +/// Strawberry, +/// Tomato, +/// } +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// FruitSelected(Fruit), +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// combo_box( +/// &state.fruits, +/// "Select your favorite fruit...", +/// state.favorite.as_ref(), +/// Message::FruitSelected +/// ) +/// .into() +/// } +/// +/// fn update(state: &mut State, message: Message) { +/// match message { +/// Message::FruitSelected(fruit) => { +/// state.favorite = Some(fruit); +/// } +/// } +/// } +/// +/// impl std::fmt::Display for Fruit { +/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +/// f.write_str(match self { +/// Self::Apple => "Apple", +/// Self::Orange => "Orange", +/// Self::Strawberry => "Strawberry", +/// Self::Tomato => "Tomato", +/// }) +/// } +/// } +/// ``` +pub struct ComboBox< + 'a, + T, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Theme: Catalog, + Renderer: text::Renderer, +{ + state: &'a State, + text_input: TextInput<'a, TextInputEvent, Theme, Renderer>, + font: Option, + selection: text_input::Value, + on_selected: Box Message>, + on_option_hovered: Option Message>>, + on_open: Option, + on_close: Option, + on_input: Option Message>>, + padding: Padding, + size: Option, + text_shaping: text::Shaping, + menu_class: ::Class<'a>, + menu_height: Length, +} + +impl<'a, T, Message, Theme, Renderer> ComboBox<'a, T, Message, Theme, Renderer> +where + T: std::fmt::Display + Clone, + Theme: Catalog, + Renderer: text::Renderer, +{ + /// Creates a new [`ComboBox`] with the given list of options, a placeholder, + /// the current selected value, and the message to produce when an option is + /// selected. + pub fn new( + state: &'a State, + placeholder: &str, + selection: Option<&T>, + on_selected: impl Fn(T) -> Message + 'static, + ) -> Self { + let text_input = TextInput::new(placeholder, &state.value()) + .on_input(TextInputEvent::TextChanged) + .class(Theme::default_input()); + + let selection = selection.map(T::to_string).unwrap_or_default(); + + Self { + state, + text_input, + font: None, + selection: text_input::Value::new(&selection), + on_selected: Box::new(on_selected), + on_option_hovered: None, + on_input: None, + on_open: None, + on_close: None, + padding: text_input::DEFAULT_PADDING, + size: None, + text_shaping: text::Shaping::default(), + menu_class: ::default_menu(), + menu_height: Length::Shrink, + } + } + + /// Sets the message that should be produced when some text is typed into + /// the [`TextInput`] of the [`ComboBox`]. + pub fn on_input( + mut self, + on_input: impl Fn(String) -> Message + 'static, + ) -> Self { + self.on_input = Some(Box::new(on_input)); + self + } + + /// Sets the message that will be produced when an option of the + /// [`ComboBox`] is hovered using the arrow keys. + pub fn on_option_hovered( + mut self, + on_option_hovered: impl Fn(T) -> Message + 'static, + ) -> Self { + self.on_option_hovered = Some(Box::new(on_option_hovered)); + self + } + + /// Sets the message that will be produced when the [`ComboBox`] is + /// opened. + pub fn on_open(mut self, message: Message) -> Self { + self.on_open = Some(message); + self + } + + /// Sets the message that will be produced when the outside area + /// of the [`ComboBox`] is pressed. + pub fn on_close(mut self, message: Message) -> Self { + self.on_close = Some(message); + self + } + + /// Sets the [`Padding`] of the [`ComboBox`]. + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self.text_input = self.text_input.padding(self.padding); + self + } + + /// Sets the [`Renderer::Font`] of the [`ComboBox`]. + /// + /// [`Renderer::Font`]: text::Renderer + pub fn font(mut self, font: Renderer::Font) -> Self { + self.text_input = self.text_input.font(font); + self.font = Some(font); + self + } + + /// Sets the [`text_input::Icon`] of the [`ComboBox`]. + pub fn icon(mut self, icon: text_input::Icon) -> Self { + self.text_input = self.text_input.icon(icon); + self + } + + /// Sets the text sixe of the [`ComboBox`]. + pub fn size(mut self, size: impl Into) -> Self { + let size = size.into(); + + self.text_input = self.text_input.size(size); + self.size = Some(size.0); + + self + } + + /// Sets the [`LineHeight`] of the [`ComboBox`]. + pub fn line_height(self, line_height: impl Into) -> Self { + Self { + text_input: self.text_input.line_height(line_height), + ..self + } + } + + /// Sets the width of the [`ComboBox`]. + pub fn width(self, width: impl Into) -> Self { + Self { + text_input: self.text_input.width(width), + ..self + } + } + + /// Sets the height of the menu of the [`ComboBox`]. + pub fn menu_height(mut self, menu_height: impl Into) -> Self { + self.menu_height = menu_height.into(); + self + } + + /// Sets the [`text::Shaping`] strategy of the [`ComboBox`]. + pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { + self.text_shaping = shaping; + self + } + + /// Sets the style of the input of the [`ComboBox`]. + #[must_use] + pub fn input_style( + mut self, + style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a, + ) -> Self + where + ::Class<'a>: + From>, + { + self.text_input = self.text_input.style(style); + self + } + + /// Sets the style of the menu of the [`ComboBox`]. + #[must_use] + pub fn menu_style( + mut self, + style: impl Fn(&Theme) -> menu::Style + 'a, + ) -> Self + where + ::Class<'a>: From>, + { + self.menu_class = (Box::new(style) as menu::StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the input of the [`ComboBox`]. + #[cfg(feature = "advanced")] + #[must_use] + pub fn input_class( + mut self, + class: impl Into<::Class<'a>>, + ) -> Self { + self.text_input = self.text_input.class(class); + self + } + + /// Sets the style class of the menu of the [`ComboBox`]. + #[cfg(feature = "advanced")] + #[must_use] + pub fn menu_class( + mut self, + class: impl Into<::Class<'a>>, + ) -> Self { + self.menu_class = class.into(); + self + } +} + +/// The local state of a [`ComboBox`]. +#[derive(Debug, Clone)] +pub struct State { + options: Vec, + inner: RefCell>, +} + +#[derive(Debug, Clone)] +struct Inner { + value: String, + option_matchers: Vec, + filtered_options: Filtered, +} + +#[derive(Debug, Clone)] +struct Filtered { + options: Vec, + updated: Instant, +} + +impl State +where + T: Display + Clone, +{ + /// Creates a new [`State`] for a [`ComboBox`] with the given list of options. + pub fn new(options: Vec) -> Self { + Self::with_selection(options, None) + } + + /// Creates a new [`State`] for a [`ComboBox`] with the given list of options + /// and selected value. + pub fn with_selection(options: Vec, selection: Option<&T>) -> Self { + let value = selection.map(T::to_string).unwrap_or_default(); + + // Pre-build "matcher" strings ahead of time so that search is fast + let option_matchers = build_matchers(&options); + + let filtered_options = Filtered::new( + search(&options, &option_matchers, &value) + .cloned() + .collect(), + ); + + Self { + options, + inner: RefCell::new(Inner { + value, + option_matchers, + filtered_options, + }), + } + } + + /// Returns the options of the [`State`]. + /// + /// These are the options provided when the [`State`] + /// was constructed with [`State::new`]. + pub fn options(&self) -> &[T] { + &self.options + } + + /// Pushes a new option to the [`State`]. + pub fn push(&mut self, new_option: T) { + let mut inner = self.inner.borrow_mut(); + + inner.option_matchers.push(build_matcher(&new_option)); + self.options.push(new_option); + + inner.filtered_options = Filtered::new( + search(&self.options, &inner.option_matchers, &inner.value) + .cloned() + .collect(), + ); + } + + /// Returns ownership of the options of the [`State`]. + pub fn into_options(self) -> Vec { + self.options + } + + fn value(&self) -> String { + let inner = self.inner.borrow(); + + inner.value.clone() + } + + fn with_inner(&self, f: impl FnOnce(&Inner) -> O) -> O { + let inner = self.inner.borrow(); + + f(&inner) + } + + fn with_inner_mut(&self, f: impl FnOnce(&mut Inner)) { + let mut inner = self.inner.borrow_mut(); + + f(&mut inner); + } + + fn sync_filtered_options(&self, options: &mut Filtered) { + let inner = self.inner.borrow(); + + inner.filtered_options.sync(options); + } +} + +impl Default for State +where + T: Display + Clone, +{ + fn default() -> Self { + Self::new(Vec::new()) + } +} + +impl Filtered +where + T: Clone, +{ + fn new(options: Vec) -> Self { + Self { + options, + updated: Instant::now(), + } + } + + fn empty() -> Self { + Self { + options: vec![], + updated: Instant::now(), + } + } + + fn update(&mut self, options: Vec) { + self.options = options; + self.updated = Instant::now(); + } + + fn sync(&self, other: &mut Filtered) { + if other.updated != self.updated { + *other = self.clone(); + } + } +} + +struct Menu { + menu: menu::State, + hovered_option: Option, + new_selection: Option, + filtered_options: Filtered, +} + +#[derive(Debug, Clone)] +enum TextInputEvent { + TextChanged(String), +} + +impl Widget + for ComboBox<'_, T, Message, Theme, Renderer> +where + T: Display + Clone + 'static, + Message: Clone, + Theme: Catalog, + Renderer: text::Renderer, +{ + fn size(&self) -> Size { + Widget::::size(&self.text_input) + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + self.text_input.layout( + &mut tree.children[0], + renderer, + limits, + (!is_focused).then_some(&self.selection), + ) + } + + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::>() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Menu:: { + menu: menu::State::new(), + filtered_options: Filtered::empty(), + hovered_option: Some(0), + new_selection: None, + }) + } + + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.text_input as &dyn Widget<_, _, _>)] + } + + fn diff(&self, _tree: &mut widget::Tree) { + // do nothing so the children don't get cleared + } + + 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 menu = tree.state.downcast_mut::>(); + + let started_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + // This is intended to check whether or not the message buffer was empty, + // since `Shell` does not expose such functionality. + let mut published_message_to_shell = false; + + // Create a new list of local messages + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + // Provide it to the widget + self.text_input.update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + + if local_shell.is_event_captured() { + shell.capture_event(); + } + + shell.request_redraw_at(local_shell.redraw_request()); + shell.request_input_method(local_shell.input_method()); + + // Then finally react to them here + for message in local_messages { + let TextInputEvent::TextChanged(new_value) = message; + + if let Some(on_input) = &self.on_input { + shell.publish((on_input)(new_value.clone())); + } + + // Couple the filtered options with the `ComboBox` + // value and only recompute them when the value changes, + // instead of doing it in every `view` call + self.state.with_inner_mut(|state| { + menu.hovered_option = Some(0); + state.value = new_value; + + state.filtered_options.update( + search( + &self.state.options, + &state.option_matchers, + &state.value, + ) + .cloned() + .collect(), + ); + }); + shell.invalidate_layout(); + shell.request_redraw(); + } + + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + if is_focused { + self.state.with_inner(|state| { + if !started_focused + && let Some(on_option_hovered) = &mut self.on_option_hovered + { + let hovered_option = menu.hovered_option.unwrap_or(0); + + if let Some(option) = + state.filtered_options.options.get(hovered_option) + { + shell.publish(on_option_hovered(option.clone())); + published_message_to_shell = true; + } + } + + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named_key), + modifiers, + .. + }) = event + { + let shift_modifier = modifiers.shift(); + match (named_key, shift_modifier) { + (key::Named::Enter, _) => { + if let Some(index) = &menu.hovered_option + && let Some(option) = + state.filtered_options.options.get(*index) + { + menu.new_selection = Some(option.clone()); + } + + shell.capture_event(); + shell.request_redraw(); + } + (key::Named::ArrowUp, _) | (key::Named::Tab, true) => { + if let Some(index) = &mut menu.hovered_option { + if *index == 0 { + *index = state + .filtered_options + .options + .len() + .saturating_sub(1); + } else { + *index = index.saturating_sub(1); + } + } else { + menu.hovered_option = Some(0); + } + + if let Some(on_option_hovered) = + &mut self.on_option_hovered + && let Some(option) = + menu.hovered_option.and_then(|index| { + state + .filtered_options + .options + .get(index) + }) + { + // Notify the selection + shell.publish((on_option_hovered)( + option.clone(), + )); + published_message_to_shell = true; + } + + shell.capture_event(); + shell.request_redraw(); + } + (key::Named::ArrowDown, _) + | (key::Named::Tab, false) + if !modifiers.shift() => + { + if let Some(index) = &mut menu.hovered_option { + if *index + >= state + .filtered_options + .options + .len() + .saturating_sub(1) + { + *index = 0; + } else { + *index = index.saturating_add(1).min( + state + .filtered_options + .options + .len() + .saturating_sub(1), + ); + } + } else { + menu.hovered_option = Some(0); + } + + if let Some(on_option_hovered) = + &mut self.on_option_hovered + && let Some(option) = + menu.hovered_option.and_then(|index| { + state + .filtered_options + .options + .get(index) + }) + { + // Notify the selection + shell.publish((on_option_hovered)( + option.clone(), + )); + published_message_to_shell = true; + } + + shell.capture_event(); + shell.request_redraw(); + } + _ => {} + } + } + }); + } + + // If the overlay menu has selected something + self.state.with_inner_mut(|state| { + if let Some(selection) = menu.new_selection.take() { + // Clear the value and reset the options and menu + state.value = String::new(); + state.filtered_options.update(self.state.options.clone()); + menu.menu = menu::State::default(); + + // Notify the selection + shell.publish((self.on_selected)(selection)); + published_message_to_shell = true; + + // Unfocus the input + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + self.text_input.update( + &mut tree.children[0], + &Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left, + )), + layout, + mouse::Cursor::Unavailable, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + shell.request_input_method(local_shell.input_method()); + } + }); + + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + if started_focused != is_focused { + // Focus changed, invalidate widget tree to force a fresh `view` + shell.invalidate_widgets(); + + if !published_message_to_shell { + if is_focused { + if let Some(on_open) = self.on_open.take() { + shell.publish(on_open); + } + } else if let Some(on_close) = self.on_close.take() { + shell.publish(on_close); + } + } + } + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.text_input.mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + let selection = if is_focused || self.selection.is_empty() { + None + } else { + Some(&self.selection) + }; + + self.text_input.draw( + &tree.children[0], + renderer, + theme, + layout, + cursor, + selection, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'_>, + _renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + if is_focused { + let Menu { + menu, + filtered_options, + hovered_option, + .. + } = tree.state.downcast_mut::>(); + + self.state.sync_filtered_options(filtered_options); + + if filtered_options.options.is_empty() { + None + } else { + let bounds = layout.bounds(); + + let mut menu = menu::Menu::new( + menu, + &filtered_options.options, + hovered_option, + |selection| { + self.state.with_inner_mut(|state| { + state.value = String::new(); + state.filtered_options.update(self.state.options.clone()); + }); + + tree.children[0] + .state + .downcast_mut::>() + .unfocus(); + + (self.on_selected)(selection) + }, + self.on_option_hovered.as_deref(), + &self.menu_class, + ) + .width(bounds.width) + .padding(self.padding) + .text_shaping(self.text_shaping); + + if let Some(font) = self.font { + menu = menu.font(font); + } + + if let Some(size) = self.size { + menu = menu.text_size(size); + } + + Some(menu.overlay( + layout.position() + translation, + *viewport, + bounds.height, + self.menu_height, + )) + } + } else { + None + } + } +} + +impl<'a, T, Message, Theme, Renderer> + From> + for Element<'a, Message, Theme, Renderer> +where + T: Display + Clone + 'static, + Message: Clone + 'a, + Theme: Catalog + 'a, + Renderer: text::Renderer + 'a, +{ + fn from(combo_box: ComboBox<'a, T, Message, Theme, Renderer>) -> Self { + Self::new(combo_box) + } +} + +/// The theme catalog of a [`ComboBox`]. +pub trait Catalog: text_input::Catalog + menu::Catalog { + /// The default class for the text input of the [`ComboBox`]. + fn default_input<'a>() -> ::Class<'a> { + ::default() + } + + /// The default class for the menu of the [`ComboBox`]. + fn default_menu<'a>() -> ::Class<'a> { + ::default() + } +} + +impl Catalog for Theme {} + +fn search<'a, T, A>( + options: impl IntoIterator + 'a, + option_matchers: impl IntoIterator + 'a, + query: &'a str, +) -> impl Iterator + 'a +where + A: AsRef + 'a, +{ + let query: Vec = query + .to_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .map(String::from) + .collect(); + + options + .into_iter() + .zip(option_matchers) + // Make sure each part of the query is found in the option + .filter_map(move |(option, matcher)| { + if query.iter().all(|part| matcher.as_ref().contains(part)) { + Some(option) + } else { + None + } + }) +} + +fn build_matchers<'a, T>( + options: impl IntoIterator + 'a, +) -> Vec +where + T: Display + 'a, +{ + options.into_iter().map(build_matcher).collect() +} + +fn build_matcher(option: T) -> String +where + T: Display, +{ + let mut matcher = option.to_string(); + matcher.retain(|c| c.is_ascii_alphanumeric()); + matcher.to_lowercase() +} diff --git a/crates/vendor/iced_widget/src/container.rs b/crates/vendor/iced_widget/src/container.rs new file mode 100644 index 00000000..f83784d1 --- /dev/null +++ b/crates/vendor/iced_widget/src/container.rs @@ -0,0 +1,660 @@ +//! 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, + 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>, + ) -> 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) -> Self { + self.id = Some(id.into()); + self + } + + /// Sets the [`Padding`] of the [`Container`]. + pub fn 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) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Container`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the maximum width of the [`Container`]. + pub fn max_width(mut self, max_width: impl Into) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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, + ) -> 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, + ) -> 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>, + { + 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>) -> Self { + self.class = class.into(); + self + } +} + +impl Widget + 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 { + self.content.as_widget().children() + } + + fn diff(&self, tree: &mut Tree) { + self.content.as_widget().diff(tree); + } + + fn size(&self) -> Size { + 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> { + self.content.as_widget_mut().overlay( + tree, + layout.children().next().unwrap(), + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + 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: &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, + /// The [`Background`] of the container. + pub background: Option, + /// 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) -> Self { + Self { + text_color: Some(color.into()), + ..self + } + } + + /// Updates the border of the [`Style`]. + pub fn border(self, border: impl Into) -> Self { + Self { + border: border.into(), + ..self + } + } + + /// Updates the background of the [`Style`]. + pub fn background(self, background: impl Into) -> Self { + Self { + background: Some(background.into()), + ..self + } + } + + /// Updates the shadow of the [`Style`]. + pub fn shadow(self, shadow: impl Into) -> Self { + Self { + shadow: shadow.into(), + ..self + } + } +} + +impl From for Style { + fn from(color: Color) -> Self { + Self::default().background(color) + } +} + +impl From for Style { + fn from(gradient: Gradient) -> Self { + Self::default().background(gradient) + } +} + +impl From 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 Style + 'a>; + +impl From