388 lines
12 KiB
Rust
388 lines
12 KiB
Rust
//! 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<String>,
|
|
optional: bool,
|
|
) -> Result<Self, &'static str> {
|
|
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<Id>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct State {
|
|
context: Context,
|
|
invocation: Id,
|
|
results: u64,
|
|
serial: u64,
|
|
open: bool,
|
|
candidates: Vec<Searchable>,
|
|
filtered: Vec<usize>,
|
|
query: String,
|
|
start: usize,
|
|
highlight: Option<usize>,
|
|
pub current: Current,
|
|
pub scope_label: String,
|
|
}
|
|
impl State {
|
|
pub fn open(
|
|
context: Context,
|
|
candidates: Vec<Candidate>,
|
|
current: Current,
|
|
scope_label: impl Into<String>,
|
|
) -> Result<Self, &'static str> {
|
|
// 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<Item = &Candidate> {
|
|
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
|
|
}
|
|
}
|