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); impl iced::advanced::Clipboard for Clipboard { fn read(&self, _: iced::advanced::clipboard::Kind) -> Option { self.0.clone() } fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) { self.0 = Some(text); } } struct Mount { tree: Option, 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)], ) -> (Vec, Option) { 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 { 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 { 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 { 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)> { 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::>(), (10_000..10_006).map(Id::from_u128).collect::>() ); 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()); }