Exercise bounded reference chooser with mounted input tests

This commit is contained in:
Codex
2026-09-11 02:29:15 -07:00
parent c6d296a7e2
commit c4e1580bb0
7 changed files with 2277 additions and 9 deletions
@@ -760,10 +760,9 @@ fn scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(cursor + scene.scroll_offset),
) {
if let Some(message) = action.into_inner().0 {
messages.push(message);
}
) && let Some(message) = action.into_inner().0
{
messages.push(message);
}
}
assert!(
+2
View File
@@ -18,6 +18,8 @@ mod context_fields;
mod data_fields;
mod history_fields;
mod port_fields;
#[cfg(test)]
mod reference_chooser;
mod view_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -579,11 +579,11 @@ fn render<'a>(
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let item_path = format!("{path}/{index}");
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
if path == FACETS {
if let Some(current) = current_observation(draft, project, &item_path) {
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
if path == FACETS
&& let Some(current) = current_observation(draft, project, &item_path)
{
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
card = card.push(row![
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
@@ -0,0 +1,387 @@
//! 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
}
}
@@ -0,0 +1,803 @@
use super::*;
use iced::{
Element, Event as Input, Point, Rectangle, Size, Theme, Vector,
advanced::{Layout, Shell, layout, widget::Tree},
keyboard, mouse,
widget::{Space, mouse_area},
};
fn context(optional: bool) -> Context {
Context::live(
Id::from_u128(1),
7,
Id::from_u128(2),
Id::from_u128(3),
"/body/SequenceUse/sequence",
optional,
)
.unwrap()
}
fn candidate(index: u128) -> Candidate {
Candidate {
id: Id::from_u128(10_000 + index),
name: "Control".into(),
alias: format!("controller_{index:04}"),
kind: "Function".into(),
scope: "SA".into(),
containment: format!("System / Subsystem {} / Controller", index / 100),
}
}
fn thousand(current: Current, optional: bool) -> State {
// Reverse the fixture input to falsify accidental dependence on ingestion
// order. Expected IDs and aliases below are literal fixture identities.
State::open(
context(optional),
(0..1_000).rev().map(candidate).collect(),
current,
"SA · compatible functions",
)
.unwrap()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Message {
Chooser(Event),
UnderPress,
UnderRelease,
}
#[derive(Default)]
struct Clipboard(Option<String>);
impl iced::advanced::Clipboard for Clipboard {
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
self.0.clone()
}
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
self.0 = Some(text);
}
}
struct Mount {
tree: Option<Tree>,
size: Size,
anchor: Rectangle,
clipboard: Clipboard,
}
impl Default for Mount {
fn default() -> Self {
Self {
tree: None,
size: Size::new(1100., 700.),
anchor: Rectangle {
x: 790.,
y: 640.,
width: 278.,
height: 32.,
},
clipboard: Clipboard::default(),
}
}
}
impl Mount {
fn batch(
&mut self,
state: Option<&State>,
events: &[(Input, Option<Point>)],
) -> (Vec<Message>, Option<layout::Node>) {
let underneath: Element<'_, Message, Theme, ()> =
mouse_area(Space::new().width(iced::Fill).height(iced::Fill))
.on_press(Message::UnderPress)
.on_release(Message::UnderRelease)
.into();
let mut view: Element<'_, Message, Theme, ()> =
widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into();
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
let viewport = Rectangle::with_size(self.size);
let overlay_node = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
let mut messages = Vec::new();
for (event, point) in events {
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
let mut shell = Shell::new(&mut messages);
if let Some(overlay_node) = &overlay_node {
// Reconstruct the overlay between layout and every input, as
// Iced does. Geometry must live in the measured layout tree.
let mut overlay = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.unwrap();
overlay.as_overlay_mut().update(
event,
Layout::new(overlay_node),
cursor,
&(),
&mut self.clipboard,
&mut shell,
);
overlay.as_overlay().draw(
&mut (),
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: iced::Color::BLACK,
},
Layout::new(overlay_node),
cursor,
);
if matches!(
event,
Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_)
) {
assert!(shell.is_event_captured(), "An open chooser owns input");
}
} else {
view.as_widget_mut().update(
tree,
event,
Layout::new(&node),
cursor,
&(),
&mut self.clipboard,
&mut shell,
&viewport,
);
}
}
(messages, overlay_node)
}
fn send(&mut self, state: &mut State, event: Input) -> Vec<Outcome> {
let (messages, _) = self.batch(Some(state), &[(event, None)]);
messages
.into_iter()
.map(|message| match message {
Message::Chooser(event) => state.update(event),
_ => panic!("Popup leaked input to its parent"),
})
.collect()
}
fn type_text(&mut self, state: &mut State, text: &str) -> Vec<Outcome> {
let (messages, _) = self.batch(Some(state), &typing(text));
messages
.into_iter()
.map(|message| match message {
Message::Chooser(event) => state.update(event),
_ => panic!("Typing leaked input to its parent"),
})
.collect()
}
fn geometry(&mut self, state: &State) -> widget::Geometry {
let (_, node) = self.batch(Some(state), &[]);
widget::Geometry::from_bounds(node.unwrap().children()[0].bounds())
}
fn click(&mut self, state: &State, point: Point) -> Vec<Message> {
self.batch(
Some(state),
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
),
(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
),
],
)
.0
}
}
fn named(key: keyboard::key::Named) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
// Iced 0.14's physical-key path intentionally inserts one scalar from text.
// Whole-string typing is several key events; paste and IME commit are separate
// input paths and are exercised below rather than faked as one physical key.
fn typed(character: char) -> Input {
let text = character.to_string();
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(text.clone().into()),
modified_key: keyboard::Key::Character(text.clone().into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(text.into()),
repeat: false,
})
}
fn typing(text: &str) -> Vec<(Input, Option<Point>)> {
text.chars()
.map(|character| (typed(character), None))
.collect()
}
fn query(state: &mut State, value: &str) {
let event = state.event(Action::Query {
text: value.into(),
serial: state.query_serial() + 1,
});
assert_eq!(state.update(event), Outcome::Browsed);
}
#[test]
fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() {
let mut state = thousand(Current::Unassigned, false);
assert_eq!(
state.visible(6).map(|c| c.id).collect::<Vec<_>>(),
(10_000..10_006).map(Id::from_u128).collect::<Vec<_>>()
);
query(&mut state, "control subsystem 6 @controller_0617");
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617));
query(&mut state, "OA");
assert_eq!(state.count(), 0);
query(&mut state, &Id::from_u128(10_617).to_string());
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().alias, "controller_0617");
let placement = Candidate {
id: Id::from_u128(20_001),
name: "Control".into(),
alias: "control_placement".into(),
kind: "Placement".into(),
scope: "SA".into(),
containment: "Architecture / Main diagram / Control".into(),
};
let mut state = State::open(
context(false),
vec![placement],
Current::Unassigned,
"SA · placements",
)
.unwrap();
query(&mut state, "placement main");
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001));
let event = state.event(Action::Choose(Id::from_u128(20_000)));
assert_eq!(
state.update(event),
Outcome::Ignored,
"Semantic ID is not the placement identity"
);
assert!(
State::open(
context(false),
vec![candidate(1), candidate(1)],
Current::Unassigned,
"SA"
)
.is_err()
);
}
#[test]
fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() {
let state = thousand(Current::InvalidRaw("not a uuid".into()), true);
for size in [
Size::new(1100., 700.),
Size::new(1600., 1000.),
Size::new(320., 320.),
] {
for y in [5., size.height / 2., size.height - 34.] {
let mut mount = Mount {
size,
anchor: Rectangle {
x: size.width - 300.,
y,
width: 278.,
height: 32.,
},
..Default::default()
};
let (_, node) = mount.batch(Some(&state), &[]);
let node = node.unwrap();
let bounds = node.children()[0].bounds();
let geometry = widget::Geometry::from_bounds(bounds);
assert!(bounds.x >= 8. && bounds.y >= 8.);
assert!(bounds.x + bounds.width <= size.width - 8.);
assert!(bounds.y + bounds.height <= size.height - 8.);
assert!(bounds.width <= 420. && bounds.height <= 360.);
assert!(geometry.rows > 0 && geometry.rows <= 6);
assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 });
let search = node.children()[1].bounds();
assert!(
bounds.contains(search.position())
&& bounds.contains(Point::new(
search.x + search.width,
search.y + search.height
))
);
assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y);
assert_eq!(state.visible(geometry.rows).count(), geometry.rows);
}
}
}
#[test]
fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]);
assert_eq!(
messages.len(),
2,
"Actual focused input must publish each edit"
);
let mut queries = Vec::new();
for message in messages {
let Message::Chooser(event) = message else {
panic!("Unexpected parent event")
};
let Action::Query { text, .. } = &event.action else {
panic!("Typing must not choose a reference")
};
queries.push(text.clone());
assert_eq!(state.update(event), Outcome::Browsed);
}
assert_eq!(queries, ["a", "ab"]);
assert_eq!(state.query(), "ab");
assert!(
mount
.send(&mut state, named(keyboard::key::Named::Home))
.is_empty()
);
assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]);
assert_eq!(
state.query(),
"zab",
"Rebuilding the controlled widget must not refocus at the end"
);
assert_eq!(state.current, Current::Unassigned);
}
#[test]
fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::PageDown)),
[Outcome::Browsed]
);
assert_eq!(state.start(), 6);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006));
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::ArrowDown)),
[Outcome::Browsed]
);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007));
let (messages, _) = mount.batch(
Some(&state),
&[
(named(keyboard::key::Named::Enter), None),
(named(keyboard::key::Named::Enter), None),
],
);
assert_eq!(messages.len(), 1);
let Message::Chooser(event) = messages.into_iter().next().unwrap() else {
panic!()
};
assert!(event.action.writes_staged_value());
assert_eq!(
state.update(event),
Outcome::Write {
context: context(false),
value: Some(Id::from_u128(10_007))
}
);
assert!(!state.is_open());
}
#[test]
fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() {
let mut state = thousand(Current::Missing(Id::from_u128(99)), false);
let mut mount = Mount::default();
let query = "@controller_0731";
assert_eq!(
mount.type_text(&mut state, query),
vec![Outcome::Browsed; query.chars().count()]
);
assert_eq!(state.query(), query);
assert_eq!(state.count(), 1);
let point = mount.geometry(&state).row(0).center();
let messages = mount.click(&state, point);
assert_eq!(messages.len(), 1);
let Message::Chooser(event) = messages[0].clone() else {
panic!()
};
assert_eq!(
state.update(event),
Outcome::Write {
context: context(false),
value: Some(Id::from_u128(10_731))
}
);
assert_eq!(state.current, Current::Missing(Id::from_u128(99)));
}
#[test]
fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let point = mount.geometry(&state).row(4).center();
let captured = mount.click(&state, point);
let Message::Chooser(old_choice) = captured[0].clone() else {
panic!()
};
query(&mut state, "@controller_0700");
assert_eq!(state.update(old_choice), Outcome::Ignored);
// The rejected old Choose had marked the widget closing; a changed result
// context must unlock it so an explicit current click can complete.
let point = mount.geometry(&state).row(0).center();
let messages = mount.click(&state, point);
assert_eq!(messages.len(), 1);
let Message::Chooser(event) = messages[0].clone() else {
panic!()
};
assert_eq!(
state.update(event),
Outcome::Write {
context: context(false),
value: Some(Id::from_u128(10_700))
}
);
let mut state = thousand(Current::Unassigned, false);
let point = mount.geometry(&state).row(0).center();
assert!(
mount
.batch(
Some(&state),
&[(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point)
)]
)
.0
.is_empty()
);
query(&mut state, "@controller_0319");
assert!(
mount
.batch(
Some(&state),
&[(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
)]
)
.0
.is_empty()
);
assert!(state.is_open());
}
#[test]
fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() {
let source = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let point = mount.geometry(&source).row(0).center();
let Message::Chooser(event) = mount.click(&source, point).remove(0) else {
panic!()
};
for changed in 0..6 {
let mut destination = context(false);
match changed {
0 => destination.document = Id::from_u128(999),
1 => destination.revision += 1,
2 => destination.draft = Id::from_u128(999),
3 => destination.record = Id::from_u128(999),
4 => destination.path = "/body/SomeList/members/1/scenario".into(),
_ => {} // A new invocation even with every semantic ID unchanged.
}
let mut state = State::open(
destination,
vec![candidate(0)],
Current::InvalidRaw("unfinished".into()),
"SA",
)
.unwrap();
assert_eq!(state.update(event.clone()), Outcome::Ignored);
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
assert!(state.is_open());
}
let mut invalidated = source.clone();
invalidated.invalidate();
assert_eq!(invalidated.update(event), Outcome::Ignored);
}
#[test]
fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() {
for current in [
Current::Missing(Id::from_u128(999)),
Current::InvalidRaw("@incomplete/".into()),
Current::Incompatible(candidate(999)),
] {
let mut state = thousand(current.clone(), false);
let mut mount = Mount::default();
let query = "no match exists";
assert_eq!(
mount.type_text(&mut state, query),
vec![Outcome::Browsed; query.chars().count()]
);
assert_eq!(state.query(), query);
assert_eq!(
state.count(),
0,
"Verify actual input reached the empty-result state before Enter"
);
assert!(
mount
.send(&mut state, named(keyboard::key::Named::Enter))
.is_empty()
);
assert!(state.is_open());
assert_eq!(state.current, current);
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::Escape)),
[Outcome::Closed(Close::Escape)]
);
assert_eq!(state.current, current);
}
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::Tab)),
[Outcome::Closed(Close::Tab { backwards: false })]
);
let mut state = thousand(Current::InvalidRaw("?".into()), true);
let point = mount.geometry(&state).clear.center();
let Message::Chooser(event) = mount.click(&state, point).remove(0) else {
panic!()
};
assert_eq!(
state.update(event),
Outcome::Write {
context: context(true),
value: None
}
);
let mut required = thousand(Current::Unassigned, false);
let clear = required.event(Action::Clear);
assert_eq!(required.update(clear), Outcome::Ignored);
}
#[test]
fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let outside = Point::new(20., 20.);
let (messages, _) = mount.batch(
Some(&state),
&[(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(outside),
)],
);
let [Message::Chooser(event)] = messages.as_slice() else {
panic!("Expected only popup dismissal")
};
assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside));
let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
assert!(
mount
.batch(None, &[(release.clone(), Some(outside))])
.0
.is_empty()
);
assert_eq!(
mount
.batch(
None,
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(outside)
),
(release, Some(outside)),
]
)
.0,
[Message::UnderPress, Message::UnderRelease]
);
}
#[test]
fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() {
for path in [
"/baseline",
"/baseline/oa/value",
"/baseline/sa/value/TypeRef/Named",
] {
assert!(
Context::live(
Id::from_u128(1),
0,
Id::from_u128(2),
Id::from_u128(3),
path,
true
)
.is_err()
);
let mut forged = context(true);
forged.path = path.into();
assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err());
}
for action in [
Action::Query {
text: "Control".into(),
serial: 1,
},
Action::Highlight(Id::from_u128(10_000)),
Action::Move { delta: 1, rows: 6 },
Action::Page { delta: 1, rows: 6 },
Action::Close(Close::Escape),
] {
assert!(!action.writes_staged_value());
}
assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value());
assert!(Action::Clear.writes_staged_value());
}
#[test]
fn out_of_order_query_serials_cannot_restore_obsolete_results() {
let mut state = thousand(Current::Unassigned, false);
let old = state.event(Action::Query {
text: "@controller_0001".into(),
serial: 1,
});
let new = state.event(Action::Query {
text: "@controller_0999".into(),
serial: 2,
});
assert_eq!(state.update(new), Outcome::Browsed);
assert_eq!(state.update(old), Outcome::Ignored);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999));
}
#[test]
fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() {
let mut state = thousand(Current::InvalidRaw("unfinished".into()), false);
let mut mount = Mount::default();
let query = "@controller_0720";
let mut inputs = typing(query);
inputs.push((named(keyboard::key::Named::Escape), None));
let (messages, _) = mount.batch(Some(&state), &inputs);
let outcomes: Vec<_> = messages
.into_iter()
.map(|message| {
let Message::Chooser(event) = message else {
panic!("Input escaped the popup")
};
state.update(event)
})
.collect();
let mut expected = vec![Outcome::Browsed; query.chars().count()];
expected.push(Outcome::Closed(Close::Escape));
assert_eq!(outcomes, expected);
assert_eq!(state.query(), "@controller_0720");
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
let mut state = thousand(Current::Unassigned, false);
let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000)));
let (messages, _) = mount.batch(
Some(&state),
&[
(named(keyboard::key::Named::PageDown), None),
(named(keyboard::key::Named::PageDown), None),
(named(keyboard::key::Named::ArrowUp), None),
],
);
assert_eq!(messages.len(), 3);
for message in messages {
let Message::Chooser(event) = message else {
panic!("Input escaped the popup")
};
assert_eq!(state.update(event), Outcome::Browsed);
}
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011));
assert_eq!(state.start(), 11);
assert_eq!(state.update(old_absolute_row), Outcome::Ignored);
assert!(state.is_open());
}
#[test]
fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
mount.clipboard.0 = Some("@controller_0472".into());
let paste = Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("v".into()),
modified_key: keyboard::Key::Character("v".into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::COMMAND,
text: None,
repeat: false,
});
let (messages, _) = mount.batch(
Some(&state),
&[
(
Input::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::COMMAND,
)),
None,
),
(paste, None),
(
Input::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::empty(),
)),
None,
),
],
);
let [Message::Chooser(event)] = messages.as_slice() else {
panic!("Paste should emit exactly one whole-string query")
};
assert_eq!(state.update(event.clone()), Outcome::Browsed);
assert_eq!(state.query(), "@controller_0472");
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472));
assert_eq!(state.current, Current::Unassigned);
assert!(state.is_open());
let unicode_name = "Режим Δ負荷";
let unicode = Candidate {
id: Id::from_u128(30_111),
name: unicode_name.into(),
alias: "mode_unicode".into(),
kind: "Function".into(),
scope: "SA".into(),
containment: "Система / 控制".into(),
};
let mut state = State::open(
context(false),
vec![unicode],
Current::InvalidRaw("retained".into()),
"SA",
)
.unwrap();
use iced::advanced::input_method::Event as Ime;
let preedit = [
(Input::InputMethod(Ime::Opened), None),
(
Input::InputMethod(Ime::Preedit(unicode_name.into(), None)),
None,
),
];
assert!(
mount.batch(Some(&state), &preedit).0.is_empty(),
"Composition is not a committed query"
);
assert_eq!(state.query(), "");
let (messages, _) = mount.batch(
Some(&state),
&[
(Input::InputMethod(Ime::Preedit(String::new(), None)), None),
(Input::InputMethod(Ime::Commit(unicode_name.into())), None),
(Input::InputMethod(Ime::Closed), None),
],
);
let [Message::Chooser(event)] = messages.as_slice() else {
panic!("IME commit should emit one complete query")
};
assert_eq!(state.update(event.clone()), Outcome::Browsed);
assert_eq!(state.query(), unicode_name);
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111));
assert_eq!(state.current, Current::InvalidRaw("retained".into()));
assert!(state.is_open());
}
@@ -0,0 +1,957 @@
//! Root overlay adapter. Keep the wrapper mounted while closed so the release
//! from a dismissal gesture cannot reach the underlying content. Parent modal
//! ownership is supplied by the host (pass no chooser while a modal is active).
use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser};
use crate::style;
use iced::{
Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector,
advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
text::{self, Paragraph as _},
widget::{Operation, Tree, tree},
},
alignment, keyboard, mouse, touch,
widget::text_input,
window,
};
use std::{cell::Cell, rc::Rc};
const ROW: f32 = 34.;
const MARGIN: f32 = 8.;
#[derive(Debug, Clone, Copy)]
pub(super) struct Geometry {
pub popup: Rectangle,
pub search: Rectangle,
pub current: Rectangle,
pub detail: Rectangle,
pub close: Rectangle,
pub previous: Rectangle,
pub next: Rectangle,
pub clear: Rectangle,
pub rows: usize,
}
impl Geometry {
fn new(anchor: Rectangle, viewport: Size) -> Self {
let width = anchor
.width
.max(278.)
.min(420.)
.min((viewport.width - 2. * MARGIN).max(0.));
let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.));
let x = anchor.x.clamp(
MARGIN.min(viewport.width / 2.),
(viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)),
);
let below = anchor.y + anchor.height + 4.;
let preferred = if below + height + MARGIN <= viewport.height {
below
} else {
anchor.y - height - 4.
};
let y = preferred.clamp(
MARGIN.min(viewport.height / 2.),
(viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)),
);
Self::from_bounds(Rectangle {
x,
y,
width,
height,
})
}
pub(super) fn from_bounds(popup: Rectangle) -> Self {
let inner = (popup.width - 16.).max(0.);
let rect = |left, top, width, height| Rectangle {
x: popup.x + left,
y: popup.y + top,
width,
height,
};
let footer = (popup.height - 32.).max(0.);
let detail_height = if popup.height >= 180. { 32. } else { 0. };
let detail_top = (footer - detail_height - 4.).max(84.);
let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS);
Self {
popup,
search: rect(8., 28., inner, 30.),
current: rect(8., 62., inner, 18.),
detail: rect(8., detail_top, inner, detail_height),
close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.),
previous: rect(8., footer, 28., 24.),
next: rect(40., footer, 28., 24.),
clear: rect((popup.width - 84.).max(0.), footer, 76., 24.),
rows,
}
}
pub fn row(&self, index: usize) -> Rectangle {
Rectangle {
x: self.popup.x + 8.,
y: self.popup.y + 84. + index as f32 * ROW,
width: (self.popup.width - 16.).max(0.),
height: ROW,
}
}
fn hit(&self, chooser: &Chooser, point: Point) -> Option<Action> {
if self.close.contains(point) {
return Some(Action::Close(Close::Escape));
}
if self.previous.contains(point) && chooser.start() > 0 {
return Some(Action::Page {
delta: -1,
rows: self.rows,
});
}
if self.next.contains(point) && chooser.start() + self.rows < chooser.count() {
return Some(Action::Page {
delta: 1,
rows: self.rows,
});
}
if self.clear.contains(point) && chooser.optional() {
return Some(Action::Clear);
}
chooser
.visible(self.rows)
.enumerate()
.find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id)))
}
}
#[derive(Default)]
struct Interaction {
stamp: Option<Stamp>,
serial: u64,
pressed: Option<(ChoiceEvent, Pointer)>,
closing: bool,
swallow_mouse: Option<mouse::Button>,
swallow_touch: Option<touch::Finger>,
held_mouse: Option<mouse::Button>,
held_touch: Option<touch::Finger>,
detail_scroll: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pointer {
Mouse,
Touch(touch::Finger),
}
impl Interaction {
fn swallow(&mut self, event: &Event) -> bool {
match event {
Event::Mouse(mouse::Event::ButtonReleased(button))
if self.swallow_mouse == Some(*button) =>
{
self.swallow_mouse = None;
true
}
Event::Touch(
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
) if self.swallow_touch == Some(*id) => {
self.swallow_touch = None;
true
}
_ => false,
}
}
}
pub struct Popup<'a, Message, R = iced::Renderer>
where
R: text::Renderer<Font = Font>,
{
content: Element<'a, Message, Theme, R>,
input: Element<'a, String, Theme, R>,
chooser: Option<&'a Chooser>,
anchor: Rectangle,
on_event: Rc<dyn Fn(ChoiceEvent) -> Message + 'a>,
}
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> Popup<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
chooser: Option<&'a Chooser>,
anchor: Rectangle,
on_event: impl Fn(ChoiceEvent) -> Message + 'a,
) -> Self {
let chooser = chooser.filter(|state| state.is_open());
let input = text_input(
"Name, @alias, kind or containment",
chooser.map_or("", Chooser::query),
)
.on_input(std::convert::identity)
.size(12)
.padding(6)
.width(Length::Fill)
.into();
Self {
content: content.into(),
input,
chooser,
anchor,
on_event: Rc::new(on_event),
}
}
fn reconcile(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Interaction>();
let stamp = self.chooser.map(Chooser::stamp);
if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) {
let input = tree.children[1]
.state
.downcast_mut::<text_input::State<R::Paragraph>>();
if stamp.is_some() {
input.focus();
} else {
input.unfocus();
}
state.closing = false;
state.serial = self.chooser.map_or(0, Chooser::query_serial);
}
if state.stamp != stamp {
state.pressed = None;
// A queued Choose can be rejected after a preceding query changed
// the result generation. Reconciliation must unlock this view.
state.closing = false;
state.detail_scroll = 0.;
}
state.stamp = stamp;
}
}
impl<Message: Clone, R: text::Renderer<Font = Font>> Widget<Message, Theme, R>
for Popup<'_, Message, R>
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Interaction>()
}
fn state(&self) -> tree::State {
tree::State::new(Interaction::default())
}
fn children(&self) -> Vec<Tree> {
vec![
Tree::new(self.content.as_widget()),
Tree::new(self.input.as_widget()),
]
}
fn diff(&self, tree: &mut Tree) {
tree.children[0].diff(self.content.as_widget());
tree.children[1].diff(self.input.as_widget());
self.reconcile(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.reconcile(tree);
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<Interaction>();
if state.swallow(event) || self.chooser.is_some() && is_input(event) {
shell.capture_event();
return;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
if self.chooser.is_some() {
mouse::Cursor::Unavailable
} else {
cursor
},
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if self.chooser.is_some() {
mouse::Interaction::None
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
if let Some(chooser) = self.chooser {
Some(overlay::Element::new(Box::new(ChooserOverlay {
chooser,
anchor: self.anchor,
state: tree.state.downcast_mut(),
input: &mut self.input,
input_tree: &mut tree.children[1],
on_event: &*self.on_event,
})))
} else {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
}
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> From<Popup<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(popup: Popup<'a, Message, R>) -> Self {
Element::new(popup)
}
}
struct ChooserOverlay<'b, 'a, Message, R: text::Renderer<Font = Font>> {
chooser: &'a Chooser,
anchor: Rectangle,
state: &'b mut Interaction,
input: &'b mut Element<'a, String, Theme, R>,
input_tree: &'b mut Tree,
on_event: &'b dyn Fn(ChoiceEvent) -> Message,
}
impl<Message, R: text::Renderer<Font = Font>> ChooserOverlay<'_, '_, Message, R> {
fn geometry(&self, layout: Layout<'_>) -> Geometry {
Geometry::from_bounds(
layout
.children()
.next()
.expect("measured chooser bounds")
.bounds(),
)
}
fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) {
if self.state.closing {
return;
}
if action.writes_staged_value() || matches!(action, Action::Close(_)) {
self.state.closing = true;
self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse);
self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch);
}
shell.publish((self.on_event)(self.chooser.event(action)));
}
fn press(
&mut self,
point: Point,
pointer: Pointer,
geometry: Geometry,
shell: &mut Shell<'_, Message>,
) {
if !geometry.popup.contains(point) {
self.publish(Action::Close(Close::Outside), shell);
} else {
self.state.pressed = geometry
.hit(self.chooser, point)
.map(|action| (self.chooser.event(action), pointer));
}
}
fn release(
&mut self,
point: Point,
pointer: Pointer,
geometry: Geometry,
shell: &mut Shell<'_, Message>,
) {
if let Some((pressed, origin)) = self.state.pressed.take()
&& origin == pointer
&& pressed.stamp == self.chooser.stamp()
&& geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action)
{
self.publish(pressed.action, shell);
}
}
}
impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, Theme, R>
for ChooserOverlay<'_, '_, Message, R>
{
fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node {
let geometry = Geometry::new(self.anchor, bounds);
let input = self
.input
.as_widget_mut()
.layout(
self.input_tree,
renderer,
&layout::Limits::new(Size::ZERO, geometry.search.size()),
)
.move_to(geometry.search.position());
layout::Node::with_children(
bounds,
vec![
layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()),
input,
],
)
}
fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) {
self.input.as_widget_mut().operate(
self.input_tree,
layout.children().nth(1).unwrap(),
renderer,
operation,
);
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let geometry = self.geometry(layout);
match event {
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
self.state.held_mouse = Some(*button)
}
Event::Mouse(mouse::Event::ButtonReleased(button))
if self.state.held_mouse == Some(*button) =>
{
self.state.held_mouse = None
}
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
self.state.held_touch = Some(*id)
}
Event::Touch(
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
) if self.state.held_touch == Some(*id) => self.state.held_touch = None,
_ => {}
}
if self.state.swallow(event) {
shell.capture_event();
return;
}
if self.state.closing {
if is_input(event) {
shell.capture_event();
}
return;
}
let mut handled = false;
match event {
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
use keyboard::key::Named;
let action = match key {
keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)),
keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab {
backwards: modifiers.shift(),
})),
keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move {
delta: 1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move {
delta: -1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::PageDown) => Some(Action::Page {
delta: 1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::PageUp) => Some(Action::Page {
delta: -1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::Enter) => {
handled = true;
self.chooser
.highlighted()
.filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id))
.map(|c| Action::Choose(c.id))
}
keyboard::Key::Character(c)
if modifiers.control()
&& modifiers.shift()
&& c.eq_ignore_ascii_case("c") =>
{
clipboard.write(
iced::advanced::clipboard::Kind::Standard,
self.chooser
.highlighted()
.map_or_else(|| self.chooser.current.label(), |c| c.detail()),
);
handled = true;
None
}
_ => None,
};
if let Some(action) = action {
self.publish(action, shell);
handled = true;
}
}
Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => {
self.publish(Action::Close(Close::WindowChanged), shell);
handled = true;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if let Some(point) = cursor.position() {
if !geometry.popup.contains(point) {
self.state.swallow_mouse = Some(*button);
self.press(point, Pointer::Mouse, geometry, shell);
handled = true;
} else if *button == mouse::Button::Left && !geometry.search.contains(point) {
self.press(point, Pointer::Mouse, geometry, shell);
handled = true;
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
if self.state.pressed.is_some() =>
{
if let Some(point) = cursor.position() {
self.release(point, Pointer::Mouse, geometry, shell);
} else {
self.state.pressed = None;
}
handled = true;
}
Event::Mouse(mouse::Event::CursorMoved { position })
if self.state.pressed.is_none() =>
{
if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position)
&& self
.chooser
.highlighted()
.is_none_or(|candidate| candidate.id != id)
{
self.publish(Action::Highlight(id), shell);
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let (x, y) = match delta {
mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.),
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
};
self.state.pressed = None;
if cursor
.position()
.is_some_and(|p| geometry.detail.contains(p))
{
let detail = self
.chooser
.highlighted()
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
let max = (measure::<R>(&detail, 11., renderer.default_font())
- geometry.detail.width)
.max(0.);
self.state.detail_scroll =
(self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max);
shell.request_redraw();
} else if y != 0. {
self.publish(
Action::Page {
delta: if y < 0. { 1 } else { -1 },
rows: geometry.rows,
},
shell,
);
}
handled = true;
}
Event::Touch(touch::Event::FingerPressed { id, position })
if !geometry.search.contains(*position) =>
{
if !geometry.popup.contains(*position) {
self.state.swallow_touch = Some(*id);
}
self.press(*position, Pointer::Touch(*id), geometry, shell);
handled = true;
}
Event::Touch(touch::Event::FingerLifted { id, position })
if self.state.pressed.is_some() =>
{
self.release(*position, Pointer::Touch(*id), geometry, shell);
handled = true;
}
Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => {
self.state.pressed = None;
}
_ => {}
}
if !handled {
let mut messages = Vec::new();
let mut input_shell = Shell::new(&mut messages);
self.input.as_widget_mut().update(
self.input_tree,
event,
layout.children().nth(1).unwrap(),
cursor,
renderer,
clipboard,
&mut input_shell,
&geometry.popup,
);
let serial = Cell::new(self.state.serial);
shell.merge(input_shell, |text| {
serial.set(serial.get().saturating_add(1));
(self.on_event)(self.chooser.event(Action::Query {
text,
serial: serial.get(),
}))
});
self.state.serial = serial.get();
}
if is_input(event) {
shell.capture_event();
}
}
fn draw(
&self,
renderer: &mut R,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
let geometry = self.geometry(layout);
renderer.fill_quad(
renderer::Quad {
bounds: geometry.popup,
border: Border {
color: style::LINE,
width: 1.,
radius: 8.into(),
},
..Default::default()
},
Color::WHITE,
);
renderer.with_layer(geometry.popup, |renderer| {
label(
renderer,
&self.chooser.scope_label,
11.,
style::MUTED,
Rectangle {
x: geometry.popup.x + 8.,
y: geometry.popup.y + 4.,
width: (geometry.popup.width - 78.).max(0.),
height: 22.,
},
);
label(renderer, "Close", 11., style::TEAL, geometry.close);
label(
renderer,
&self.chooser.current.label(),
11.,
style::MUTED,
geometry.current,
);
for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() {
let row = geometry.row(i);
if self
.chooser
.highlighted()
.is_some_and(|c| c.id == candidate.id)
{
renderer.fill_quad(
renderer::Quad {
bounds: row,
..Default::default()
},
Color::from_rgb8(218, 238, 235),
);
}
let alias_width = (row.width * 0.42).min(
measure::<R>(
&format!("@{}", candidate.alias),
11.,
renderer.default_font(),
) + 4.,
);
label(
renderer,
&candidate.name,
12.,
style::INK,
Rectangle {
width: (row.width - alias_width - 6.).max(0.),
height: 17.,
..row
},
);
label(
renderer,
&format!("@{}", candidate.alias),
11.,
style::INK,
Rectangle {
x: row.x + row.width - alias_width,
width: alias_width,
height: 17.,
..row
},
);
label(
renderer,
&candidate.context_label(),
10.,
style::MUTED,
Rectangle {
y: row.y + 17.,
height: 17.,
..row
},
);
}
if self.chooser.count() == 0 {
label(
renderer,
"No compatible matches. Clear the query.",
11.,
style::MUTED,
geometry.row(0),
);
}
let detail = self
.chooser
.highlighted()
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
renderer.with_layer(geometry.detail, |renderer| {
raw_label(
renderer,
detail,
11.,
style::MUTED,
Rectangle {
x: geometry.detail.x - self.state.detail_scroll,
width: geometry.detail.width + self.state.detail_scroll,
height: 16.,
..geometry.detail
},
);
label(
renderer,
"Scroll details · Ctrl+Shift+C copies",
10.,
style::MUTED,
Rectangle {
y: geometry.detail.y + 16.,
height: 16.,
..geometry.detail
},
);
});
label(
renderer,
"‹",
15.,
if self.chooser.start() > 0 {
style::TEAL
} else {
style::MUTED
},
geometry.previous,
);
label(
renderer,
"›",
15.,
if self.chooser.start() + geometry.rows < self.chooser.count() {
style::TEAL
} else {
style::MUTED
},
geometry.next,
);
let count = if self.chooser.count() == 0 {
"0 results".into()
} else {
format!(
"{}–{} / {}",
self.chooser.start() + 1,
(self.chooser.start() + geometry.rows).min(self.chooser.count()),
self.chooser.count()
)
};
label(
renderer,
&count,
11.,
style::MUTED,
Rectangle {
x: geometry.next.x + 30.,
width: (geometry.clear.x - geometry.next.x - 32.).max(0.),
..geometry.next
},
);
if self.chooser.optional() {
label(
renderer,
"Clear reference",
10.,
style::TEAL,
geometry.clear,
);
}
self.input.as_widget().draw(
self.input_tree,
renderer,
theme,
renderer_style,
layout.children().nth(1).unwrap(),
cursor,
&geometry.popup,
);
});
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
) -> mouse::Interaction {
let geometry = self.geometry(layout);
if cursor
.position()
.is_some_and(|p| geometry.hit(self.chooser, p).is_some())
{
mouse::Interaction::Pointer
} else {
self.input.as_widget().mouse_interaction(
self.input_tree,
layout.children().nth(1).unwrap(),
cursor,
&geometry.popup,
renderer,
)
}
}
}
fn is_input(event: &Event) -> bool {
matches!(
event,
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
)
}
fn measure<R: text::Renderer<Font = Font>>(content: &str, size: f32, font: Font) -> f32 {
R::Paragraph::with_text(text::Text {
content,
bounds: Size::INFINITE,
size: size.into(),
line_height: text::LineHeight::default(),
font,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::None,
})
.min_width()
}
fn label<R: text::Renderer<Font = Font>>(
renderer: &mut R,
content: &str,
size: f32,
color: Color,
bounds: Rectangle,
) {
let font = renderer.default_font();
let mut display = content.to_owned();
if measure::<R>(content, size, font) > bounds.width {
let ends: Vec<_> = content
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(content.len()))
.collect();
let (mut low, mut high) = (0, ends.len());
while low + 1 < high {
let middle = (low + high) / 2;
if measure::<R>(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width
{
low = middle;
} else {
high = middle;
}
}
display = format!("{}…", &content[..ends[low]]);
}
raw_label(renderer, display, size, color, bounds);
}
fn raw_label<R: text::Renderer<Font = Font>>(
renderer: &mut R,
content: String,
size: f32,
color: Color,
bounds: Rectangle,
) {
renderer.fill_text(
text::Text {
content,
bounds: bounds.size(),
size: size.into(),
line_height: text::LineHeight::default(),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::None,
},
Point::new(bounds.x, bounds.center_y()),
color,
bounds,
);
}