804 lines
27 KiB
Rust
804 lines
27 KiB
Rust
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());
|
|
}
|