Dismiss pickers with Escape before cancelling outer drafts

This commit is contained in:
2026-09-23 20:34:33 -07:00
parent 301126f609
commit e9167f5cc8
14 changed files with 1097 additions and 370 deletions
+4 -17
View File
@@ -14,6 +14,7 @@ mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
pub(crate) mod escape;
mod hosted;
mod reference_authoring;
#[cfg(test)]
@@ -1164,7 +1165,7 @@ impl App {
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
)
.into();
crate::source_navigation::interaction::observe(
let content = crate::source_navigation::interaction::observe(
crate::behavior::scenario::focus::root(
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(
@@ -1176,7 +1177,8 @@ impl App {
self.scenario_focus_owner(),
),
self.source_symbols.raw_interaction.clone(),
)
);
escape::root(self, content)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
@@ -1218,7 +1220,6 @@ impl App {
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
iced::event::listen_with(|event, _, _| {
matches!(
event,
@@ -1226,20 +1227,6 @@ impl App {
)
.then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary))
}),
// Use one captured-Escape bridge: a focused Find input consumes
// Escape, while modal priority remains in the ordinary handler.
behavior::creation::subscription(
self.text_workspace.menu().is_none()
&& (self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.text_workspace.drawer().is_some()
|| self.source_navigation.open
|| self.source_rename.open())
&& self.architecture.is_none())),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
if matches!(event, iced::Event::Window(window::Event::Unfocused)) {
return Some(Message::WorkDrag(allocation_drag::Event::Unfocused));
@@ -27,30 +27,11 @@ pub enum Event {
Cancel,
}
pub fn subscription(app: &App) -> Subscription<Message> {
if app.architecture.as_ref().is_some_and(State::inline) {
iced::event::listen_with(captured_escape)
} else {
Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
// A focused Iced text input captures Escape while dropping its focus. Only
// inline architecture naming opts into also cancelling that same keypress.
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(Message::Architecture(Event::CapturedEscape))
pub(super) fn captured_escape_message(app: &App) -> Option<Message> {
app.architecture
.as_ref()
.is_some_and(State::inline)
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -233,65 +233,6 @@ fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
}
}
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard;
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn focused_text_input_escape(name: &str) -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
.id("architecture-name")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(field.as_widget());
let size = iced::Size::new(300., 40.);
let node =
field
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let escape = key_event(iced::keyboard::key::Named::Escape);
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
field.as_widget_mut().update(
&mut tree,
&escape,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(
shell.is_event_captured(),
"Iced consumes focused-input Escape"
);
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
captured_escape(
escape,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap()
}
#[test]
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
for rename in [false, true] {
@@ -311,8 +252,9 @@ fn architecture_first_focused_input_escape_cancels_create_and_rename() {
let name = " Unaccepted long engineering name 雨 ";
send(&mut app, Event::Name(name.into()));
assert!(app.architecture.as_ref().unwrap().inline());
let message = focused_text_input_escape(name);
let _ = app.update(message);
let mut messages = crate::app::escape::tests::focused_input_messages(&app);
assert_eq!(messages.len(), 1);
let _ = app.update(messages.remove(0));
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
@@ -342,8 +284,8 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
let before = app.document.bundle().clone();
let status = app.status.clone();
let epoch = app.canvas_epoch;
let message = focused_text_input_escape("Other field or existing-element search");
let _ = app.update(message);
let messages = crate::app::escape::tests::focused_input_messages(&app);
assert!(messages.is_empty());
assert_eq!(
app.architecture
.as_ref()
@@ -355,19 +297,7 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
assert_eq!(app.canvas_epoch, epoch);
}
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Escape),
iced::event::Status::Ignored,
iced::window::Id::unique(),
)
.is_none()
);
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Enter),
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.is_none()
crate::app::escape::tests::focused_input_key(&app, iced::keyboard::key::Named::Enter,)
.is_empty()
);
}
+198
View File
@@ -0,0 +1,198 @@
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
//!
//! Iced skips root.update when an overlay captures an event. Keeping this
//! wrapper present even when inactive preserves the child's widget state. A
//! current-overlay preflight also handles pickers opened mid-batch. Passive
//! overlays may see an ignored Escape again: the raw-input observer only makes
//! pending focus requests stale; it does not publish modal cancellation.
use super::{App, Message, Page, architecture_authoring};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
app: &App,
content: Element<'a, Message, Theme, R>,
) -> Element<'a, Message, Theme, R> {
let modal = app.text_workspace.menu().is_none()
&& (app.behavior.creation.has_pending()
|| app.diagram_usages.is_some()
|| app.behavior.source_active()
|| (app.page == Page::Text
&& (app.text_workspace.drawer().is_some()
|| app.source_navigation.open
|| app.source_rename.open())
&& app.architecture.is_none()));
Element::new(Bridge {
content,
messages: [
architecture_authoring::captured_escape_message(app),
modal.then_some(Message::Escape),
],
})
}
struct Bridge<'a, R> {
content: Element<'a, Message, Theme, R>,
messages: [Option<Message>; 2],
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
);
// Iced collects overlays before processing a batch of base events. A
// preceding click in that batch can therefore open a picker whose
// Escape would otherwise reach focused fields underneath it. Offer only
// Escape to the current overlay before updating the ordinary tree.
if escape {
if let Some(element) = self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
Vector::ZERO,
) {
let mut overlay = overlay::Nested::new(element);
let node = overlay.layout(renderer, viewport.size());
overlay.update(
event,
Layout::new(&node),
cursor,
renderer,
clipboard,
shell,
);
}
if shell.is_event_captured() {
return;
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if shell.is_event_captured() && escape {
for message in self.messages.iter().flatten() {
shell.publish(message.clone());
}
}
}
fn draw(
&self,
tree: &widget::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,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(test)]
pub(crate) mod tests;
+618
View File
@@ -0,0 +1,618 @@
use super::*;
use crate::behavior::canvas_workspace::tests::key;
use iced::advanced::{subscription, widget::operation};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::key::Named;
use iced::widget::{column, container, pick_list, text_input};
use iced_runtime::user_interface::{Cache, UserInterface};
use oa_core::*;
use std::task::{Context, Poll};
struct FocusInputs;
impl widget::Operation for FocusInputs {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn operation::Focusable,
) {
state.focus();
}
}
pub(crate) fn subscriptions(
app: &App,
event: Event,
status: iced::event::Status,
messages: &mut Vec<Message>,
) {
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status,
};
let mut boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = Context::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
if matches!(
message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
boundaries, 1,
"one passive allocation keyboard notification"
);
}
/// A real focused text input, production root bridge, UserInterface dispatch,
/// and actual subscriptions. Existing app-policy tests use this instead of
/// pretending every captured event came from a text control.
pub(crate) fn focused_input_messages(app: &App) -> Vec<Message> {
focused_input_key(app, Named::Escape)
}
pub(crate) fn focused_input_key(app: &App, named: Named) -> Vec<Message> {
input_messages(app, named, true)
}
pub(crate) fn input_messages(app: &App, named: Named, focused: bool) -> Vec<Message> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let input: Element<'_, Message, Theme, ()> = text_input("Retained input", "Uncommitted 雨")
.on_input(|_| Message::Escape)
.into();
let mut renderer = ();
let mut ui = UserInterface::build(
root(app, input),
Size::new(400., 100.),
Cache::new(),
&mut renderer,
);
if focused {
ui.operate(&renderer, &mut FocusInputs);
}
let event = key(named);
let mut messages = vec![];
let (_, statuses) = ui.update(
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
if named == Named::Escape {
assert_eq!(
statuses,
[if focused {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
}]
);
}
subscriptions(app, event, statuses[0], &mut messages);
messages
}
#[derive(Clone, Debug, PartialEq)]
enum PickMessage {
Selected(&'static str),
Opened,
Closed,
}
fn picker(
selected: Option<&'static str>,
notify: bool,
) -> Element<'static, PickMessage, Theme, ()> {
let list = pick_list(["Alpha", "Beta"], selected, PickMessage::Selected)
.width(200)
.padding(10)
.text_size(16)
.on_open(PickMessage::Opened);
if notify {
list.on_close(PickMessage::Closed).into()
} else {
list.into()
}
}
fn update<M>(
ui: &mut UserInterface<'_, M, Theme, ()>,
events: &[Event],
cursor: mouse::Cursor,
) -> (Vec<M>, Vec<iced::event::Status>) {
let mut messages = vec![];
let (_, statuses) = ui.update(
events,
cursor,
&mut (),
&mut iced::advanced::clipboard::Null,
&mut messages,
);
(messages, statuses)
}
fn press_at(point: iced::Point) -> (Vec<Event>, mouse::Cursor) {
(
vec![
Event::Mouse(mouse::Event::CursorMoved { position: point }),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
}
#[test]
fn picker_overlay_escape_closes_once_reopens_selects_and_retains_outside_click() {
for selected in [None, Some("Alpha")] {
for notify in [false, true] {
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
Cache::new(),
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 15.));
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
let (messages, statuses) = update(
&mut ui,
&[key(Named::Escape), key(Named::Escape)],
mouse::Cursor::Unavailable,
);
assert_eq!(
messages,
if notify {
vec![PickMessage::Closed]
} else {
vec![]
}
);
assert_eq!(
statuses,
[iced::event::Status::Captured, iced::event::Status::Ignored]
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
let (outside, cursor) = press_at(iced::Point::new(350., 250.));
assert_eq!(
update(&mut ui, &outside, cursor).0,
if notify {
vec![PickMessage::Closed]
} else {
vec![]
}
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 15.));
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
// Default 16px text uses 1.3 line-height: button/option height 40.8.
let (option, cursor) = press_at(iced::Point::new(50., 100.));
assert_eq!(
update(&mut ui, &option, cursor).0,
[PickMessage::Selected("Beta")]
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(Some("Beta"), notify),
Size::new(400., 300.),
cache,
&mut (),
);
assert_eq!(
update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable),
(vec![], vec![iced::event::Status::Ignored])
);
}
}
}
#[test]
fn initially_closed_picker_handles_open_then_escape_in_one_runtime_batch() {
for notify in [false, true] {
let mut ui = UserInterface::build(
picker(Some("Alpha"), notify),
Size::new(400., 300.),
Cache::new(),
&mut (),
);
let (mut events, cursor) = press_at(iced::Point::new(50., 15.));
events.push(key(Named::Escape));
let (messages, statuses) = update(&mut ui, &events, cursor);
assert_eq!(
messages,
if notify {
vec![PickMessage::Opened, PickMessage::Closed]
} else {
vec![PickMessage::Opened]
}
);
assert_eq!(statuses.last(), Some(&iced::event::Status::Captured));
assert_eq!(
update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable),
(vec![], vec![iced::event::Status::Ignored])
);
}
}
#[test]
fn dismissed_picker_ignores_a_queued_click_in_its_old_option_area() {
let mut ui = UserInterface::build(
picker(Some("Alpha"), true),
Size::new(400., 300.),
Cache::new(),
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 15.));
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(Some("Alpha"), true),
Size::new(400., 300.),
cache,
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 100.));
let mut batch = vec![key(Named::Escape)];
batch.extend(click);
let (messages, _) = update(&mut ui, &batch, cursor);
assert_eq!(
messages,
[PickMessage::Closed],
"queued old-option click must not select Beta"
);
}
fn draft_with_picker(app: &App) -> Element<'_, Message, Theme, ()> {
// This composition deliberately keeps a focused input beneath the actual
// picker overlay. It tests event ownership, not a claimed production form.
root(
app,
crate::source_navigation::interaction::observe(
column![
container(
text_input("Raw draft", "Keep exact bytes 雨").on_input(|_| Message::Escape)
)
.height(40),
pick_list([Layer::Oa, Layer::Sa], Some(Layer::Oa), Message::Layer)
.width(200)
.padding(10),
]
.into(),
app.source_symbols.raw_interaction.clone(),
),
)
}
#[test]
fn picker_overlay_precedes_focused_input_and_creation_source_drafts() {
use crate::behavior::{Event as BehaviorEvent, creation, source_draft};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
for (source, same_batch) in [(false, false), (true, false), (false, true), (true, true)] {
let mut app = App::with_project(Project::empty());
let send = |app: &mut App, event| {
let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event)));
};
send(&mut app, creation::Event::Open(Layer::Oa));
send(&mut app, creation::Event::Choose(creation::Family::Machine));
send(
&mut app,
creation::Event::Name("Retained machine 雨".into()),
);
if source {
let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap();
let invocation = serde_json::from_value(recovery["invocation"].clone()).unwrap();
send(
&mut app,
creation::Event::Source(source_draft::Event::Open(invocation)),
);
let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap();
let token =
serde_json::from_value(recovery["source"]["editing"]["token"].clone()).unwrap();
send(
&mut app,
creation::Event::Source(source_draft::Event::Edit(
token,
source_draft::Edit::Name("Uncommitted owner 雨".into()),
)),
);
assert_eq!(
app.behavior.creation.recovery_snapshot().unwrap().unwrap()["source"]["editing"]["name"],
"Uncommitted owner 雨"
);
}
let draft = app.behavior.recovery_snapshot().unwrap();
let project = app.project().clone();
let bundle = app.document.bundle().clone();
let revision = app.document.revision();
let selection = (app.selected, app.selected_node, app.active_diagram);
let camera = (app.pan, app.zoom);
let raw_epoch = app
.source_symbols
.raw_interaction
.load(std::sync::atomic::Ordering::Acquire);
let mut ui = UserInterface::build(
draft_with_picker(&app),
Size::new(500., 400.),
Cache::new(),
&mut (),
);
let event = key(Named::Escape);
let (mut click, cursor) = press_at(iced::Point::new(50., 55.));
let (mut messages, statuses) = if same_batch {
ui.operate(&(), &mut FocusInputs);
click.push(event.clone());
update(&mut ui, &click, cursor)
} else {
assert!(update(&mut ui, &click, cursor).0.is_empty());
let cache = ui.into_cache();
ui = UserInterface::build(
draft_with_picker(&app),
Size::new(500., 400.),
cache,
&mut (),
);
ui.operate(&(), &mut FocusInputs);
update(
&mut ui,
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
)
};
assert_eq!(statuses.last(), Some(&iced::event::Status::Captured));
subscriptions(&app, event, *statuses.last().unwrap(), &mut messages);
assert!(
messages.is_empty(),
"picker must not forward global modal cancellation"
);
let cache = ui.into_cache();
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
assert!(!crate::source_navigation::interaction::is_current(
&app.source_symbols.raw_interaction,
raw_epoch,
));
let mut ui = UserInterface::build(
draft_with_picker(&app),
Size::new(500., 400.),
cache,
&mut (),
);
let event = key(Named::Escape);
let (mut messages, statuses) = update(
&mut ui,
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
);
subscriptions(&app, event, statuses[0], &mut messages);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
drop(ui);
let _ = app.update(messages.remove(0));
if source {
assert!(app.behavior.creation.has_pending());
assert!(!app.behavior.creation.source_open());
assert_eq!(
app.behavior.creation.recovery_snapshot().unwrap().unwrap()["name"],
"Retained machine 雨"
);
} else {
assert!(!app.behavior.creation.has_pending());
}
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(app.selected, app.selected_node, app.active_diagram),
selection
);
assert_eq!((app.pan, app.zoom), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn ignored_tooltip_escape_preserves_modal_priority_and_rejects_stale_focus() {
use crate::source_navigation::interaction;
use iced::widget::{text, tooltip};
use std::sync::atomic::Ordering;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut app = App::with_project(Project::empty());
app.page = Page::Text;
app.source_navigation.open = true;
let project = app.project().clone();
let source = app.document.bundle().clone();
let context = (
app.selected,
app.selected_node,
app.active_diagram,
app.pan,
app.zoom,
);
let content = || -> Element<'_, Message, Theme, ()> {
let field = text_input("Find", "Retained query 雨").on_input(|_| Message::Escape);
let tip = tooltip(field, text("Help"), tooltip::Position::Bottom)
.delay(std::time::Duration::ZERO);
root(
&app,
interaction::observe(tip.into(), app.source_symbols.raw_interaction.clone()),
)
};
let mut ui = UserInterface::build(content(), Size::new(400., 180.), Cache::new(), &mut ());
let point = iced::Point::new(10., 10.);
let cursor = mouse::Cursor::Available(point);
assert!(
update(
&mut ui,
&[Event::Mouse(mouse::Event::CursorMoved { position: point })],
cursor
)
.0
.is_empty()
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(content(), Size::new(400., 180.), cache, &mut ());
ui.operate(&(), &mut FocusInputs);
let epoch = app.source_symbols.raw_interaction.load(Ordering::Acquire);
let event = key(Named::Escape);
let (mut messages, statuses) = update(&mut ui, std::slice::from_ref(&event), cursor);
assert_eq!(statuses, [iced::event::Status::Captured]);
subscriptions(&app, event, statuses[0], &mut messages);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
assert!(!interaction::is_current(
&app.source_symbols.raw_interaction,
epoch
));
drop(ui);
let _ = app.update(messages.remove(0));
assert!(!app.source_navigation.open);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert_eq!(
(
app.selected,
app.selected_node,
app.active_diagram,
app.pan,
app.zoom
),
context
);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn toggling_bridge_policy_retains_focused_child_state() {
let mut app = App::with_project(Project::empty());
let content = || -> Element<'static, Message, Theme, ()> {
text_input("Raw field", "Still focused")
.on_input(|_| Message::Escape)
.into()
};
let mut ui = UserInterface::build(
root(&app, content()),
Size::new(400., 100.),
Cache::new(),
&mut (),
);
ui.operate(&(), &mut FocusInputs);
let cache = ui.into_cache();
app.page = Page::Text;
app.source_navigation.open = true;
let mut ui = UserInterface::build(root(&app, content()), Size::new(400., 100.), cache, &mut ());
let (messages, status) = update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable);
assert_eq!(status, [iced::event::Status::Captured]);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
}
#[tokio::test]
async fn mounted_creation_name_escape_keeps_pending_modal_priority() {
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::{Event as BehaviorEvent, creation};
for pending in [false, true] {
let mut app = App::with_project(Project::empty());
for event in [
creation::Event::Open(Layer::Oa),
creation::Event::Choose(creation::Family::Sequence),
creation::Event::Name("Exact unfinished process 雨".into()),
] {
let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event)));
}
app.page = Page::Behavior;
let mut mounted = Mounted::new(&mut app).await;
mounted
.perform(
&mut app,
iced::widget::operation::focus(creation::NAME_INPUT),
)
.await;
if pending {
app.pending = Some(super::super::Pending::Walkthrough);
}
let before = app.document.snapshot();
let draft = app.behavior.recovery_snapshot().unwrap();
let event = key(Named::Escape);
let (mut messages, statuses) = mounted.input_with_status(
&app,
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
);
subscriptions(&app, event, statuses[0], &mut messages);
assert_eq!(
messages
.iter()
.filter(|message| matches!(message, Message::Escape))
.count(),
1
);
for message in messages {
let _ = app.update(message);
}
if pending {
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
} else {
assert!(!app.behavior.creation.has_pending());
}
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
@@ -754,76 +754,7 @@ fn invalid_open_retains_accepted_document_but_refuses_alias_rename() {
}
fn captured_escape(app: &App) -> Vec<Message> {
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
use std::task::{Context as PollContext, Poll};
let mut input: iced::Element<'_, (), iced::Theme, ()> =
iced::widget::text_input("Source alias", "retained_alias")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut widget_messages = Vec::new();
let mut shell = Shell::new(&mut widget_messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: iced::event::Status::Captured,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut messages = Vec::new();
let mut keyboard_boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
let mut context = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
crate::app::escape::tests::focused_input_messages(app)
}
#[test]
@@ -864,99 +864,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
}
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
use std::task::{Context, Poll};
let mut input: iced::Element<'_, (), iced::Theme, ()> =
iced::widget::text_input("Find / modal input", "Retain exact text 雨")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
if focused {
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
}
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut input_messages = Vec::new();
let mut shell = Shell::new(&mut input_messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
let captured = shell.is_event_captured();
assert_eq!(captured, focused);
assert!(input_messages.is_empty());
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: if captured {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
},
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut messages = Vec::new();
let mut keyboard_boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
let mut context = Context::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
assert!(
!matches!(message, Message::CheckpointTick),
"timer must remain pending"
);
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
crate::app::escape::tests::input_messages(app, iced::keyboard::key::Named::Escape, focused)
}
#[test]
fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pending_work() {
fn captured_find_and_modal_escape_use_one_actual_delivery_and_preserve_pending_work() {
for pending in [false, true] {
for modal in 0..4 {
let mut app = fixture();
@@ -1053,7 +965,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
}
#[test]
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
fn captured_delivery_does_not_duplicate_existing_behavior_or_architecture_routes() {
let mut app = fixture();
app.text_workspace.show_drawer(Some(DrawerTab::References));
let project = app.project().clone();
@@ -4,6 +4,8 @@ use super::*;
mod attachment_escape;
#[path = "state_bend_tests.rs"]
mod bends;
#[path = "state_picker_escape_tests.rs"]
mod picker_escape;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
@@ -0,0 +1,105 @@
//! The actual State workspace picker, App root and subscriptions.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{keyboard::key::Named, mouse};
async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
let input = key(Named::Escape);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages);
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
#[tokio::test]
async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reopens_and_places() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.retain(|_, node| node.element != f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::Select(f.a));
send(&mut app, Message::Page(Page::Behavior));
let choice = Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
};
send(&mut app, Message::BehaviorDiagram(choice));
let mut mounted = Mounted::new(&mut app).await;
assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]);
let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, canvas, _) = mounted
.geometry(&app)
.focused
.into_iter()
.find(|(id, _, _)| id.as_ref() == Some(&focus_id))
.unwrap();
// The existing picker is immediately above the measured canvas, separated
// by 8px. Its 12px text and 8px padding give a 31.6px row.
let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.);
let option = iced::Point::new(picker.x, canvas.y + 8.);
let before = app.document.snapshot();
let original = app.project().clone();
let source = app.document.bundle().clone();
let selection = (
app.selected,
app.selected_node,
app.behavior.diagram.placement,
);
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
let revision = app.document.revision();
let history = (app.document.can_undo(), app.document.can_redo());
mounted.click(&mut app, picker).await;
assert!(
press_escape(&mut mounted, &mut app).await.is_empty(),
"open picker owns Escape, not the State canvas or App"
);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert_eq!(
(
app.selected,
app.selected_node,
app.behavior.diagram.placement
),
selection
);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
// If Escape left the menu open this click would close it, and the following
// option click would fail to place. This checks actual overlay dismissal.
mounted.click(&mut app, picker).await;
let messages = mounted.click(&mut app, option).await;
assert!(messages.iter().any(|message| matches!(message, Message::StateDiagram(Event::Place { element, .. }) if *element == f.b)));
assert!(state_edit::unplaced(app.project(), diagram).is_empty());
assert_eq!(app.project().elements, original.elements);
assert_eq!(app.project().relations, original.relations);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &source);
}
@@ -10,32 +10,6 @@ use serde::{Deserialize, Serialize};
const PAGE_SIZE: usize = 6;
pub const NAME_INPUT: &str = "behavior-creation-name";
// A focused text input consumes Escape. Preserve the application's modal
// priority by emitting its ordinary Escape message from this narrow bridge.
pub fn subscription(active: bool) -> iced::Subscription<()> {
if active {
iced::event::listen_with(captured_escape)
} else {
iced::Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<()> {
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Family {
Sequence,
@@ -18,59 +18,11 @@ fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
send(app, Event::Name(name.into()));
}
fn focused_escape() -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
use iced::keyboard::{self, key::Named};
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
.id(NAME_INPUT)
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
assert_eq!(
captured_escape(
event.clone(),
iced::event::Status::Ignored,
iced::window::Id::unique()
),
None
);
captured_escape(
event,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap();
Message::Escape
fn focused_escape(app: &App) -> Message {
let mut messages = crate::app::escape::tests::focused_input_messages(app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
messages.remove(0)
}
#[test]
@@ -94,12 +46,12 @@ fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation()
} else {
app.pending = Some(Pending::Walkthrough);
}
let _ = app.update(focused_escape());
let _ = app.update(focused_escape(&app));
assert!(app.file_prompt.is_none());
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(focused_escape());
let _ = app.update(focused_escape(&app));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
+49
View File
@@ -1,6 +1,7 @@
//! Build and show dropdown menus.
use crate::core::alignment;
use crate::core::border::{self, Border};
use crate::core::keyboard;
use crate::core::layout::{self, Layout};
use crate::core::mouse;
use crate::core::overlay;
@@ -15,6 +16,7 @@ use crate::core::{
};
use crate::core::{Element, Shell, Widget};
use crate::scrollable::{self, Scrollable};
use std::cell::Cell;
/// A list of selectable options.
pub struct Menu<
@@ -34,6 +36,7 @@ pub struct Menu<
hovered_option: &'a mut Option<usize>,
on_selected: Box<dyn FnMut(T) -> Message + 'a>,
on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
escape: Option<(&'a Cell<bool>, Option<Message>)>,
width: f32,
padding: Padding,
text_size: Option<Pixels>,
@@ -68,6 +71,7 @@ where
hovered_option,
on_selected: Box::new(on_selected),
on_option_hovered,
escape: None,
width: 0.0,
padding: Padding::ZERO,
text_size: None,
@@ -78,6 +82,17 @@ where
}
}
// PickList owns this flag. Other Menu consumers retain their existing
// keyboard behavior unless they explicitly opt into this private bridge.
pub(crate) fn close_on_escape(
mut self,
open: &'a Cell<bool>,
on_close: Option<Message>,
) -> Self {
self.escape = Some((open, on_close));
self
}
/// Sets the width of the [`Menu`].
pub fn width(mut self, width: f32) -> Self {
self.width = width;
@@ -170,6 +185,7 @@ where
viewport: Rectangle,
tree: &'a mut Tree,
list: Scrollable<'a, Message, Theme, Renderer>,
escape: Option<(&'a Cell<bool>, Option<Message>)>,
width: f32,
target_height: f32,
class: &'a <Theme as Catalog>::Class<'b>,
@@ -198,6 +214,7 @@ where
hovered_option,
on_selected,
on_option_hovered,
escape,
width,
padding,
font,
@@ -228,6 +245,7 @@ where
viewport,
tree: &mut state.tree,
list,
escape,
width,
target_height,
class,
@@ -278,6 +296,31 @@ where
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
// UserInterface may retain this overlay for the rest of an event batch.
// A dismissed owner must not select an option on a queued later click.
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
return;
}
// Consume dismissal in the overlay so Iced never sends the same key
// into focused controls or canvases beneath this picker. The shared
// owner flag also prevents duplicate close notifications in one batch.
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
) && let Some((open, on_close)) = &mut self.escape
&& open.replace(false)
{
if let Some(message) = on_close.take() {
shell.publish(message);
}
shell.capture_event();
shell.request_redraw();
return;
}
let bounds = layout.bounds();
self.list.update(
@@ -292,6 +335,9 @@ where
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
return mouse::Interaction::None;
}
self.list.mouse_interaction(
self.tree,
layout,
@@ -309,6 +355,9 @@ where
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
return;
}
let bounds = layout.bounds();
let style = Catalog::style(theme, self.class);
+24 -10
View File
@@ -78,6 +78,7 @@ use crate::core::{
use crate::overlay::menu::{self, Menu};
use std::borrow::Borrow;
use std::cell::Cell;
use std::f32;
/// A widget for selecting a single value from a list of options.
@@ -448,12 +449,24 @@ where
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
match event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
}) if state.is_open.replace(false) => {
// The picker may have opened earlier in this base-event batch,
// after UserInterface collected its overlays.
if let Some(on_close) = &self.on_close {
shell.publish(on_close.clone());
}
shell.capture_event();
shell.request_redraw();
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(touch::Event::FingerPressed { .. }) => {
if state.is_open {
if state.is_open.get() {
// Event wasn't processed by overlay, so cursor was clicked either outside its
// bounds or on the drop-down, either way we close the overlay.
state.is_open = false;
state.is_open.set(false);
if let Some(on_close) = &self.on_close {
shell.publish(on_close.clone());
@@ -463,7 +476,7 @@ where
} else if cursor.is_over(layout.bounds()) {
let selected = self.selected.as_ref().map(Borrow::borrow);
state.is_open = true;
state.is_open.set(true);
state.hovered_option = self
.options
.borrow()
@@ -482,7 +495,7 @@ where
}) => {
if state.keyboard_modifiers.command()
&& cursor.is_over(layout.bounds())
&& !state.is_open
&& !state.is_open.get()
{
fn find_next<'a, T: PartialEq>(
selected: &'a T,
@@ -528,7 +541,7 @@ where
let status = {
let is_hovered = cursor.is_over(layout.bounds());
if state.is_open {
if state.is_open.get() {
Status::Opened { is_hovered }
} else if is_hovered {
Status::Hovered
@@ -612,7 +625,7 @@ where
shaping,
}) => Some((*font, *code_point, *size, *line_height, *shaping)),
Handle::Dynamic { open, closed } => {
if state.is_open {
if state.is_open.get() {
Some((
open.font,
open.code_point,
@@ -703,7 +716,7 @@ where
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
let font = self.font.unwrap_or_else(|| renderer.default_font());
if state.is_open {
if state.is_open.get() {
let bounds = layout.bounds();
let on_select = &self.on_select;
@@ -713,13 +726,14 @@ where
self.options.borrow(),
&mut state.hovered_option,
|option| {
state.is_open = false;
state.is_open.set(false);
(on_select)(option)
},
None,
&self.menu_class,
)
.close_on_escape(&state.is_open, self.on_close.clone())
.width(bounds.width)
.padding(self.padding)
.font(font)
@@ -763,7 +777,7 @@ where
struct State<P: text::Paragraph> {
menu: menu::State,
keyboard_modifiers: keyboard::Modifiers,
is_open: bool,
is_open: Cell<bool>,
hovered_option: Option<usize>,
options: Vec<paragraph::Plain<P>>,
placeholder: paragraph::Plain<P>,
@@ -775,7 +789,7 @@ impl<P: text::Paragraph> State<P> {
Self {
menu: menu::State::default(),
keyboard_modifiers: keyboard::Modifiers::default(),
is_open: bool::default(),
is_open: Cell::new(false),
hovered_option: Option::default(),
options: Vec::new(),
placeholder: paragraph::Plain::default(),
+74
View File
@@ -0,0 +1,74 @@
# Picker Escape ownership — issue 35
Branch-specific design above `301126f6098286bf440c1bd1b1ed685753984723`.
[Issue 35](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/35)
owns live scope, progress and acceptance. This document records the selected
contract; it does not assert completed implementation, execution or integration.
## Required interaction
An open picker owns Escape: close its popup, request redraw and consume the key.
Publish its optional close notification at most once; do not select an option
or change the surrounding draft, model, source, history, selection or camera.
A later Escape follows the existing surrounding workflow's policy. Reopening,
selection and outside-click dismissal retain their existing behavior. Selection
does not gain a new close callback as an incidental change to this repair.
Event batches matter. A popup already closed by Escape must not select a stale
option or emit another close notification when later queued events reach the
same overlay object. This does not introduce a new global keyboard-repeat policy.
## Decision and evidence
The shared picker and its menu overlay currently omit Escape handling. Adding
widget capture alone is insufficient: application subscriptions translate some
captured Escape events back into modal cancellation. Those bridges also preserve
legitimate cancellation from focused text inputs, so simply removing them loses
existing behavior.
The selected design gives dismissal to the picker overlay and moves the existing
captured-key forwarding into a scoped wrapper around the ordinary application
widget tree. Keep the wrapper structurally present even when inactive, preserve
the existing activation predicates and message priorities, and delegate widget
state, operations, layout and overlays transparently.
The locked Iced 0.14 runtime processes overlays first and skips ordinary root
updates for events captured by an overlay (`user_interface.rs`). Nested overlays
likewise give their deepest child first refusal (`overlay/nested.rs`). Thus the
Escape from an already mounted picker cannot reach ordinary-tree forwarding,
while focused text-input cancellation can retain its previous policy. The runtime
can reuse an overlay across an event batch; close-state checks must cover that
case. Conversely, opening a picker and pressing Escape in the same batch can
reach the ordinary tree before the runtime constructs its overlay. The wrapper
therefore offers the currently available nested overlay first refusal for Escape
before forwarding to ordinary children; the picker also handles base delivery.
An overlay that already ignored Escape may receive this narrow preflight again.
Audited reachable passive overlays perform no selection or draft action; the
source-navigation observer can advance its conservative focus-freshness epoch
again. Regression checks must retain stale-focus rejection and observable state.
Do not generalize this replay to other keyboard or pointer events.
Alternatives rejected: a widget-only patch leaves outer-draft cancellation;
global open-picker bookkeeping duplicates widget state and requires coordinating
every open/select/close/removal path; per-text-input forwarding can work but
spreads the existing policy across many controls. Revisit the wrapper choice if
runtime delivery changes or actual mounted tests contradict these assumptions.
## Acceptance
Use mounted runtime delivery for open/dismiss/reopen/select, optional callbacks,
outside click and same-batch Escape/click sequences. Exercise the real application
subscriptions with returned event status, including focused text cancellation,
architecture editing, behavior drafts and Text workflows. Compare complete model
and source state where dismissal must conserve them; direct helper calls alone
do not establish event ownership.
Native coverage should include the State hidden-element picker and the Add
existing chooser's filter dropdown with a retained query: first Escape dismisses
the picker, then a distinct Escape follows the chooser policy. Behavior creation,
behavior source forms and Text Find/rename currently have no internal dropdown;
picker coexistence there is composed mounted coverage, not a claim of a native
control that does not exist. Verify their ordinary focused-text Escape behavior.
Linux remains user-paused. Shared integration and release acceptance remain with
the integration owner; this branch does not establish combined-release behavior.