Preserve loaded draft bytes and model history, reject stale workspace and input events, and keep captured Escape under modal priority. Verified 181 focused Windows tests and strict desktop Clippy on exact inputs4; earlier full737 scope and component/native preparation evidence are archived separately. Native navigation acceptance remains pending.
191 lines
6.5 KiB
Rust
191 lines
6.5 KiB
Rust
use super::*;
|
|
use iced::advanced::widget::operation::{Focusable, Outcome};
|
|
use iced::widget::{column, text_input};
|
|
use iced::{Point, Theme};
|
|
|
|
#[derive(Default)]
|
|
struct FocusMap(Vec<(Option<widget::Id>, Rectangle, bool)>);
|
|
impl widget::Operation for FocusMap {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) {
|
|
self.0.push((id.cloned(), bounds, state.is_focused()));
|
|
}
|
|
}
|
|
|
|
struct Mounted {
|
|
element: Element<'static, (), Theme, ()>,
|
|
tree: widget::Tree,
|
|
node: layout::Node,
|
|
}
|
|
impl Mounted {
|
|
fn new(clock: Arc<AtomicU64>) -> Self {
|
|
let content = column![
|
|
text_input("Editor", "source")
|
|
.id("editor")
|
|
.on_input(|_| ())
|
|
.padding(12),
|
|
text_input("Find", "")
|
|
.id("find")
|
|
.on_input(|_| ())
|
|
.padding(12),
|
|
]
|
|
.spacing(10)
|
|
.into();
|
|
let mut element = observe(content, clock);
|
|
let mut tree = widget::Tree::new(element.as_widget());
|
|
let node = element.as_widget_mut().layout(
|
|
&mut tree,
|
|
&(),
|
|
&layout::Limits::new(Size::ZERO, Size::new(400., 200.)),
|
|
);
|
|
Self {
|
|
element,
|
|
tree,
|
|
node,
|
|
}
|
|
}
|
|
fn pass(&mut self, operation: &mut dyn widget::Operation) {
|
|
self.element.as_widget_mut().operate(
|
|
&mut self.tree,
|
|
Layout::new(&self.node),
|
|
&(),
|
|
operation,
|
|
);
|
|
}
|
|
fn run(&mut self, operation: impl widget::Operation + 'static) {
|
|
let mut next: Box<dyn widget::Operation> = Box::new(operation);
|
|
loop {
|
|
self.pass(next.as_mut());
|
|
match next.finish() {
|
|
Outcome::Chain(chained) => next = chained,
|
|
Outcome::None => break,
|
|
Outcome::Some(()) => panic!("focus must not publish an application message"),
|
|
}
|
|
}
|
|
}
|
|
fn focus(&mut self) -> FocusMap {
|
|
let mut focus = FocusMap::default();
|
|
self.pass(&mut focus);
|
|
focus
|
|
}
|
|
fn click(&mut self, id: &str) {
|
|
let bounds = self
|
|
.focus()
|
|
.0
|
|
.into_iter()
|
|
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
|
.unwrap()
|
|
.1;
|
|
let mut messages = Vec::new();
|
|
let mut shell = Shell::new(&mut messages);
|
|
self.element.as_widget_mut().update(
|
|
&mut self.tree,
|
|
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
|
Layout::new(&self.node),
|
|
mouse::Cursor::Available(bounds.center()),
|
|
&(),
|
|
&mut iced::advanced::clipboard::Null,
|
|
&mut shell,
|
|
&Rectangle::with_size(Size::new(400., 200.)),
|
|
);
|
|
assert!(shell.is_event_captured());
|
|
assert!(
|
|
messages.is_empty(),
|
|
"focus-only click must not rely on App messages"
|
|
);
|
|
}
|
|
fn focused(&mut self, id: &str) -> bool {
|
|
self.focus()
|
|
.0
|
|
.into_iter()
|
|
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
|
.unwrap()
|
|
.2
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn captured_find_click_invalidates_without_app_message_and_preserves_focus() {
|
|
let clock = Arc::new(AtomicU64::new(0));
|
|
let mut ui = Mounted::new(clock.clone());
|
|
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
|
assert!(ui.focused("editor"));
|
|
ui.click("find");
|
|
assert!(!is_current(&clock, 0));
|
|
assert!(ui.focused("find"));
|
|
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
|
assert!(ui.focused("find"));
|
|
// Even a matching clock cannot authorize stealing another widget's focus.
|
|
ui.run(GuardedFocus::new(
|
|
"editor".into(),
|
|
clock.clone(),
|
|
clock.load(Ordering::Acquire),
|
|
));
|
|
assert!(ui.focused("find"));
|
|
assert!(!ui.focused("editor"));
|
|
}
|
|
|
|
#[test]
|
|
fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() {
|
|
let clock = Arc::new(AtomicU64::new(0));
|
|
let mut ui = Mounted::new(clock.clone());
|
|
let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0);
|
|
ui.pass(&mut operation);
|
|
let Outcome::Chain(mut apply) = <GuardedFocus as widget::Operation<()>>::finish(&operation)
|
|
else {
|
|
panic!("mounted, unopposed target must be eligible");
|
|
};
|
|
clock.store(1, Ordering::Release);
|
|
ui.pass(apply.as_mut());
|
|
assert!(!ui.focused("editor"));
|
|
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1));
|
|
assert!(ui.focused("editor"));
|
|
ui.run(GuardedFocus::new("removed-editor".into(), clock, 1));
|
|
assert!(ui.focused("editor"));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() {
|
|
let clock = AtomicU64::new(0);
|
|
let events = [
|
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
|
key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
|
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
|
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft),
|
|
location: keyboard::Location::Standard,
|
|
modifiers: keyboard::Modifiers::empty(),
|
|
text: None,
|
|
repeat: false,
|
|
}),
|
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
|
Event::Mouse(mouse::Event::WheelScrolled {
|
|
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
|
}),
|
|
Event::Touch(touch::Event::FingerPressed {
|
|
id: touch::Finger(1),
|
|
position: Point::ORIGIN,
|
|
}),
|
|
Event::Window(iced::window::Event::Unfocused),
|
|
];
|
|
for (index, event) in events.iter().enumerate() {
|
|
invalidate(&clock, event);
|
|
assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1);
|
|
}
|
|
invalidate(
|
|
&clock,
|
|
&Event::Mouse(mouse::Event::CursorMoved {
|
|
position: Point::ORIGIN,
|
|
}),
|
|
);
|
|
invalidate(&clock, &Event::Window(iced::window::Event::Focused));
|
|
assert!(is_current(&clock, 5));
|
|
clock.store(u64::MAX - 1, Ordering::Release);
|
|
invalidate(&clock, &events[0]);
|
|
assert!(!is_current(&clock, u64::MAX));
|
|
invalidate(&clock, &events[0]);
|
|
assert_eq!(clock.load(Ordering::Acquire), u64::MAX);
|
|
assert!(!is_current(&clock, 0));
|
|
}
|