//! Isolated live-reference chooser experiment, declared only under cfg(test). //! The host supplies an eligible catalog and revalidates every //! Write intent against the current document before changing a staged scalar. //! No core command, draft serialization, or App message belongs in this module. use oa_core::Id; use std::collections::BTreeSet; #[cfg(test)] mod tests; pub mod widget; pub const MAX_ROWS: usize = 6; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Context { pub document: Id, pub revision: u64, pub draft: Id, pub record: Id, pub path: String, pub optional: bool, } impl Context { /// This is an additional historical-path guard, not live schema discovery. /// Call only after the parent form has identified a live reference field. pub fn live( document: Id, revision: u64, draft: Id, record: Id, path: impl Into, optional: bool, ) -> Result { let path = path.into(); if !path.starts_with('/') || path.split('/').any(|part| part == "baseline") { return Err("The chooser requires a live reference property."); } Ok(Self { document, revision, draft, record, path, optional, }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Candidate { /// For a diagram placement this is the placement ID, not its element ID. pub id: Id, pub name: String, pub alias: String, pub kind: String, pub scope: String, pub containment: String, } impl Candidate { pub fn context_label(&self) -> String { format!("{} · {} · {}", self.scope, self.kind, self.containment) } pub fn detail(&self) -> String { format!( "{} · @{} · {} · {}", self.name, self.alias, self.context_label(), self.id ) } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Current { Unassigned, Valid(Candidate), Incompatible(Candidate), Missing(Id), InvalidRaw(String), } impl Current { pub fn label(&self) -> String { match self { Self::Unassigned => "Not assigned".into(), Self::Valid(c) => format!("Current: {} · @{}", c.name, c.alias), Self::Incompatible(c) => { format!("Current choice needs review: {} · @{}", c.name, c.alias) } Self::Missing(id) => format!("Missing reference: {id}"), Self::InvalidRaw(raw) => format!("Invalid reference retained: {raw}"), } } } #[derive(Debug, Clone)] struct Searchable { candidate: Candidate, all: String, alias: String, name: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Stamp { pub context: Context, pub invocation: Id, pub results: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Close { Escape, Tab { backwards: bool }, Outside, WindowChanged, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { /// The mounted text input numbers emitted edits. Multiple edits in one /// runtime batch may share the old result stamp; none may be dropped. Query { text: String, serial: u64, }, Move { delta: i32, rows: usize, }, Page { delta: i32, rows: usize, }, Highlight(Id), Choose(Id), Clear, Close(Close), } impl Action { pub fn writes_staged_value(&self) -> bool { matches!(self, Self::Choose(_) | Self::Clear) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Event { pub stamp: Stamp, pub action: Action, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { Ignored, Browsed, Closed(Close), /// The host must still check current eligibility and scoped draft guards. Write { context: Context, value: Option, }, } #[derive(Debug, Clone)] pub struct State { context: Context, invocation: Id, results: u64, serial: u64, open: bool, candidates: Vec, filtered: Vec, query: String, start: usize, highlight: Option, pub current: Current, pub scope_label: String, } impl State { pub fn open( context: Context, candidates: Vec, current: Current, scope_label: impl Into, ) -> Result { // Context fields are public for host comparisons; check again in case // a caller constructed one without the live-path constructor. if !context.path.starts_with('/') || context.path.split('/').any(|p| p == "baseline") { return Err("Historical values are not live reference candidates."); } let mut seen = BTreeSet::new(); let mut indexed = Vec::with_capacity(candidates.len()); for candidate in candidates { if candidate.id.is_nil() || !seen.insert(candidate.id) { return Err("Eligible references must have distinct, non-nil identities."); } indexed.push(Searchable { all: candidate.detail().to_lowercase(), alias: candidate.alias.to_lowercase(), name: candidate.name.to_lowercase(), candidate, }); } indexed.sort_by(|a, b| { (&a.name, &a.alias, a.candidate.id).cmp(&(&b.name, &b.alias, b.candidate.id)) }); let filtered: Vec<_> = (0..indexed.len()).collect(); let highlight = (!filtered.is_empty()).then_some(0); Ok(Self { context, invocation: Id::new_v4(), results: 0, serial: 0, open: true, candidates: indexed, filtered, query: String::new(), start: 0, highlight, current, scope_label: scope_label.into(), }) } pub fn stamp(&self) -> Stamp { Stamp { context: self.context.clone(), invocation: self.invocation, results: self.results, } } pub fn event(&self, action: Action) -> Event { Event { stamp: self.stamp(), action, } } pub fn is_open(&self) -> bool { self.open } pub fn query(&self) -> &str { &self.query } pub fn query_serial(&self) -> u64 { self.serial } pub fn count(&self) -> usize { self.filtered.len() } pub fn start(&self) -> usize { self.start } pub fn optional(&self) -> bool { self.context.optional } pub fn highlighted(&self) -> Option<&Candidate> { self.highlight.and_then(|i| self.at(i)) } fn at(&self, index: usize) -> Option<&Candidate> { self.filtered .get(index) .map(|i| &self.candidates[*i].candidate) } pub fn visible(&self, rows: usize) -> impl Iterator { self.filtered .iter() .skip(self.start) .take(rows.min(MAX_ROWS)) .map(|index| &self.candidates[*index].candidate) } /// The host calls this before rendering on any path/document/facet/context /// replacement. Existing events remain tied to the old invocation. pub fn invalidate(&mut self) { self.open = false; } pub fn update(&mut self, event: Event) -> Outcome { if !self.open || event.stamp.context != self.context || event.stamp.invocation != self.invocation { return Outcome::Ignored; } // Absolute row actions require exactly the presented result set. // Relative navigation and dismissal are processed in input order within // this session: typing must not make a following Escape stale. if matches!(event.action, Action::Choose(_) | Action::Highlight(_)) && event.stamp.results != self.results { return Outcome::Ignored; } match event.action { Action::Query { text, serial } => { if serial <= self.serial { return Outcome::Ignored; } self.serial = serial; self.query = text; let query = self.query.to_lowercase(); let terms: Vec<_> = query.split_whitespace().collect(); self.filtered = self .candidates .iter() .enumerate() .filter_map(|(i, c)| { terms .iter() .all(|term| match term.strip_prefix('@') { Some(alias) => c.alias.contains(alias), None => c.all.contains(term), }) .then_some(i) }) .collect(); self.start = 0; self.highlight = (!self.filtered.is_empty()).then_some(0); } Action::Page { delta, rows } => { let rows = rows.clamp(1, MAX_ROWS); if self.filtered.is_empty() { return Outcome::Ignored; } let relative = self .highlight .unwrap_or(self.start) .saturating_sub(self.start) .min(rows - 1); let last_page = (self.filtered.len() - 1) / rows * rows; self.start = (self.start as i64 + i64::from(delta) * rows as i64) .clamp(0, last_page as i64) as usize; self.highlight = Some((self.start + relative).min(self.filtered.len() - 1)); } Action::Move { delta, rows } => { let rows = rows.clamp(1, MAX_ROWS); if self.filtered.is_empty() { return Outcome::Ignored; } let current = self.highlight.unwrap_or(self.start) as i64; let next = (current + i64::from(delta)).clamp(0, self.filtered.len() as i64 - 1) as usize; self.highlight = Some(next); if next < self.start { self.start = next; } if next >= self.start + rows { self.start = next + 1 - rows; } } Action::Highlight(id) => { let Some(index) = self .filtered .iter() .position(|i| self.candidates[*i].candidate.id == id) else { return Outcome::Ignored; }; if self.highlight == Some(index) { return Outcome::Ignored; } self.highlight = Some(index); } Action::Choose(id) => { if !self .filtered .iter() .any(|i| self.candidates[*i].candidate.id == id) { return Outcome::Ignored; } self.open = false; return Outcome::Write { context: self.context.clone(), value: Some(id), }; } Action::Clear if self.context.optional => { self.open = false; return Outcome::Write { context: self.context.clone(), value: None, }; } Action::Clear => return Outcome::Ignored, Action::Close(reason) => { self.open = false; return Outcome::Closed(reason); } } self.results = self.results.wrapping_add(1); Outcome::Browsed } }