docking: menu key equivalents reach the text field of a native panel window (#318)

* docking: menu key equivalents reach the focused text field of a native window

On macOS the menu bar takes key equivalents such as Cmd+C and Cmd+V before
the key window sees them, and an app's menu glue reports them in the main
window's input, where they ran on the document even while a text field in a
floating panel's own window had focus. Each native window notes its text
focus as it draws. While the window with the keyboard has a focused field,
the main window's input hands that window the keys a text field acts on
(Cmd+C/X/V/A, Cmd+Z and Shift+Cmd+Z, Cmd with the arrows, Cmd+Backspace and
Cmd+Delete, and Copy, Cut and Paste events); Cmd+C and Cmd+X arrive as Copy
and Cut, and Cmd+V asks the window for the clipboard text. Other command
keys stay with the main window, and nothing moves while input is blocked.

typing_window and send_keys expose the same routing to menus that run
commands themselves.

* docking: typing_window answers between frames with the last frame's focus
This commit is contained in:
aiongg authored and GitHub committed 2026-10-25 12:23:34 -04:00
1 parent 8b19c44267
commit 5bbe9f6254
5 files changed
+213 -4

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PA
pub use render::{close_flyout, flyout, open_flyout};
pub use surface::{
DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits,
WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag,
WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, send_keys, typing_window,
};
pub use targets::{
DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, Sides, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker,
+71
View File
@@ -27,6 +27,8 @@ struct Content {
text: Option<u32>,
typed: String,
focus_text: bool,
/// The text field gives up keyboard focus on the next frame.
blur_text: bool,
/// The panel with a button that takes focus.
focused_button: Option<u32>,
/// The panel with a counter button, its clicks, and where it was drawn.
@@ -54,6 +56,10 @@ struct Content {
drops: Vec<(u32, Pos2)>,
/// Drop sites whose release found egui's drag-and-drop payload.
payload_drops: Vec<u32>,
/// Key and clipboard events panel 1 saw in the main window, and the text field's panel in
/// its own window.
main_keys: Vec<Event>,
field_keys: Vec<Event>,
}
/// How a test's app drag source marks its drag.
@@ -76,6 +82,9 @@ impl DockContent<u32> for Content {
if std::mem::take(&mut self.focus_text) {
field.request_focus();
}
if std::mem::take(&mut self.blur_text) {
field.surrender_focus();
}
}
if self.focused_button == Some(*panel) {
ui.button("OK").request_focus();
@@ -112,6 +121,13 @@ impl DockContent<u32> for Content {
}
}
self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty());
let main = ui.ctx().viewport_id() == egui::ViewportId::ROOT;
if (main && *panel == 1) || (!main && self.text == Some(*panel)) {
let keys = ui.input(|i| {
i.events.iter().filter(|e| matches!(e, Event::Key { .. } | Event::Copy | Event::Cut | Event::Paste(_))).cloned().collect::<Vec<_>>()
});
(if main { &mut self.main_keys } else { &mut self.field_keys }).extend(keys);
}
if ui.rect_contains_pointer(ui.max_rect()) {
let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b)));
self.pressed.extend(pressed.map(|b| (*panel, b)));
@@ -1838,6 +1854,61 @@ fn a_blocked_palette_stays_shown_and_behind_the_main_window_when_the_app_returns
assert_eq!(levels, expected);
}
/// A menu's key equivalent, as an app's menu glue reports it in the main window's input.
fn menu_key(key: Key) -> Vec<Event> {
let press = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND };
vec![press(true), press(false)]
}
#[test]
fn menu_keys_go_to_the_focused_text_field_of_the_native_window_with_the_keyboard() {
let has = |events: &[Event], key: Key| events.iter().any(|e| matches!(e, Event::Key { key: k, .. } if *k == key));
let pasted = |d: &DockDriver<u32, Content>, viewport| d.host().commands.contains(&(viewport, ViewportCommand::RequestPaste));
let mut d = driver(layout(true));
let viewport = d.viewport(id_of(&d));
(d.content.text, d.content.focus_text) = (Some(5), true);
d.host().focus = Some(viewport);
d.settle();
assert_eq!(super::typing_window(&d.ctx), Some(viewport));
// Between frames, where an app's `raw_input_hook` asks, the answer is the last frame's.
d.content.blur_text = true;
d.step(vec![]);
assert_eq!(super::typing_window(&d.ctx), None, "right after the field loses focus");
d.content.focus_text = true;
d.step(vec![]);
assert_eq!(super::typing_window(&d.ctx), Some(viewport), "right after the field takes focus");
d.step(menu_key(Key::V));
assert!(!has(&d.content.main_keys, Key::V), "{:?}", d.content.main_keys);
assert!(pasted(&d, viewport), "the field's window asks for the clipboard text");
d.step(menu_key(Key::C));
assert!(!has(&d.content.main_keys, Key::C));
assert!(d.content.field_keys.contains(&Event::Copy), "{:?}", d.content.field_keys);
// Saving is the app's, from any window.
d.step(menu_key(Key::S));
assert!(has(&d.content.main_keys, Key::S));
assert!(!has(&d.content.field_keys, Key::S));
// Under a block the window's field is inert: the main window keeps every key.
d.input_blocked = true;
d.settle();
assert_eq!(super::typing_window(&d.ctx), None);
d.host().commands.clear();
d.step(menu_key(Key::V));
assert!(has(&d.content.main_keys, Key::V));
assert!(!pasted(&d, viewport));
// With no focused field, nothing moves.
let mut d = driver(layout(true));
let viewport = d.viewport(id_of(&d));
d.content.text = Some(5);
d.host().focus = Some(viewport);
d.settle();
d.step(menu_key(Key::V));
assert!(has(&d.content.main_keys, Key::V));
assert!(d.content.field_keys.is_empty(), "{:?}", d.content.field_keys);
assert!(!pasted(&d, viewport));
}
#[test]
fn a_focused_field_and_a_held_press_stay_inert_across_a_block() {
let mut d = blocked_driver();
+1 -1
View File
@@ -29,7 +29,7 @@ pub(in crate::docking) use body_drag::held_events;
#[cfg(feature = "native-windows")]
pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag};
#[cfg(feature = "native-windows")]
pub(in crate::docking) use native::{DesktopCursor, InputHook, set_input_hook};
pub(in crate::docking) use native::{DesktopCursor, InputHook, send_keys, set_input_hook, typing_window};
/// How long after blocking starts a native window that still has the keyboard hands it to the
/// main window: an OS dialog the app opens with the modal (a file dialog) takes focus a little
+105 -2
View File
@@ -584,6 +584,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max);
self.cross.pointed = None;
self.floating_body(ui, window, body, full);
if own && !blocked && ui.ctx().text_edit_focused() {
ui.ctx().with_plugin(|input: &mut NativeInput| input.typing_next.insert(viewport));
}
self.cross.held |= ui.ctx().dragged_id().is_some_and(|id| self.owned_interactions.contains(&id));
if debug_close() {
debug_outlines(ui, full, &buttons);
@@ -900,6 +903,14 @@ pub(super) struct NativeInput {
pub held: Held,
/// The main window's passes so far.
pub pass: u64,
/// The windows that had a focused text field in the last main-window pass, and those
/// noted so far in this one.
typing: std::collections::HashSet<ViewportId>,
typing_next: std::collections::HashSet<ViewportId>,
/// The main window's completed passes when `typing` was last taken from `typing_next`.
typing_at: u64,
/// Key and clipboard events for each window's next input, and the pass they were queued in.
keys: std::collections::HashMap<ViewportId, (u64, Vec<Event>)>,
}
pub(in crate::docking) type InputHook = Box<dyn FnMut(&Context, &mut RawInput) + Send>;
@@ -918,10 +929,31 @@ impl egui::Plugin for NativeInput {
let pass = self.pass;
self.queued.retain(|_, (at, _)| *at + 1 >= pass);
self.held.retain(|_, (at, _)| *at + 1 >= pass);
self.keys.retain(|_, (at, _)| *at + 1 >= pass);
self.typing = std::mem::take(&mut self.typing_next);
self.typing_at = ctx.cumulative_pass_nr_for(ViewportId::ROOT);
// eframe has run `App::raw_input_hook`, where an app's native menu reports its key
// equivalents (macOS: AppKit's menu takes them before the key window sees them).
// While a native window has the keyboard, the main window gets no keys of its own,
// so these are the menu's: those a focused text field there acts on are its own.
if let Some(window) = typing_window_of(&self.typing, &input.viewports) {
let (moved, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut input.events).into_iter().partition(text_key);
input.events = kept;
if !moved.is_empty() {
let entry = self.keys.entry(window).or_default();
entry.0 = pass;
entry.1.extend(moved);
ctx.request_repaint_of(window);
}
}
}
// Taken before routing, which may queue more for this window's next input. A held
// release waits for a later main-window pass: a discarded pass reruns within its own.
let waiting = self.queued.remove(&viewport);
let keys = self.keys.remove(&viewport).map(|(_, mut keys)| {
text_clipboard_keys(ctx, viewport, &mut keys);
keys
});
let held = if self.held.get(&viewport).is_some_and(|(at, _)| *at < self.pass) { self.held.remove(&viewport) } else { None };
let mut routed = false;
for route in self.routes.values_mut() {
@@ -929,8 +961,11 @@ impl egui::Plugin for NativeInput {
}
// Before the window's own events: they happened earlier, and a held release must not
// end a press that follows it.
let earlier =
held.into_iter().flat_map(|(_, events)| events).chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e)));
let earlier = held
.into_iter()
.flat_map(|(_, events)| events)
.chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e)))
.chain(keys.into_iter().flatten());
input.events.splice(0..0, earlier);
if routed {
ctx.request_repaint_of(ViewportId::ROOT);
@@ -941,6 +976,74 @@ impl egui::Plugin for NativeInput {
}
}
/// The native window in `typing` that has the keyboard, by the main window's `viewports`.
fn typing_window_of(typing: &std::collections::HashSet<ViewportId>, viewports: &egui::ViewportIdMap<ViewportInfo>) -> Option<ViewportId> {
typing.iter().copied().find(|viewport| viewports.get(viewport).and_then(|info| info.focused) == Some(true))
}
/// Whether a text field acts on `event`: the clipboard events, and the command keys that copy,
/// paste, select all, undo, redo, move or delete in text. Other command keys are the app's
/// (save, close, new window, its own shortcuts).
fn text_key(event: &Event) -> bool {
match event {
Event::Copy | Event::Cut | Event::Paste(_) => true,
Event::Key { key, modifiers: m, .. } if m.command && !m.alt => match key {
Key::C | Key::X | Key::V | Key::A | Key::Backspace | Key::Delete => !m.shift,
Key::Z | Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => true,
_ => false,
},
_ => false,
}
}
/// A text field copies and pastes on `Copy`, `Cut` and `Paste` events, which egui-winit makes
/// from the window's own key presses. A command C, X or V press here is a menu's key
/// equivalent, which a text field ignores: give back C and X as `Copy` and `Cut`, and turn V
/// into a request for the clipboard text, which arrives in `viewport` as `Paste`.
fn text_clipboard_keys(ctx: &Context, viewport: ViewportId, events: &mut Vec<Event>) {
events.retain_mut(|e| {
let Event::Key { key, pressed: true, modifiers: m, .. } = *e else { return true };
if !m.command || m.alt || m.shift {
return true;
}
match key {
Key::C => *e = Event::Copy,
Key::X => *e = Event::Cut,
Key::V => {
ctx.send_viewport_cmd_to(viewport, ViewportCommand::RequestPaste);
return false;
}
_ => {}
}
true
});
}
/// See [`crate::docking::typing_window`].
pub(in crate::docking) fn typing_window(ctx: &Context) -> Option<ViewportId> {
// Between frames (in `App::raw_input_hook`) a main-window pass has ended since the swap, and
// `typing_next` is the whole of the last pass, which the next swap hands to the routing.
let passes = ctx.cumulative_pass_nr_for(ViewportId::ROOT);
let typing =
ctx.with_plugin(|input: &mut NativeInput| if passes > input.typing_at { input.typing_next.clone() } else { input.typing.clone() })?;
ctx.input_for(ViewportId::ROOT, |i| typing_window_of(&typing, &i.raw.viewports))
}
/// See [`crate::docking::send_keys`].
pub(in crate::docking) fn send_keys(ctx: &Context, viewport: ViewportId, events: Vec<Event>) {
if events.is_empty() {
return;
}
let queued = ctx.with_plugin(|input: &mut NativeInput| {
let entry = input.keys.entry(viewport).or_default();
entry.0 = input.pass;
entry.1.extend(events);
});
if queued.is_some() {
ctx.request_repaint_of(viewport);
}
}
/// Set or clear the input hook of the dock area `area` for this frame, and forget its native
/// windows until they show again.
pub(in crate::docking) fn set_input_hook(ctx: &Context, area: Id, hook: Option<InputHook>) {
+35
View File
@@ -791,6 +791,41 @@ pub fn route_app_drag(ctx: &Context, area_id: Id) {
let _ = (ctx, area_id);
}
/// The native window that has the keyboard and a focused text field in its last pass (feature
/// `native-windows`; elsewhere `None`). Not a window drawn under [`DockArea::input_blocked`].
///
/// On macOS the menu bar takes key equivalents such as ⌘C and ⌘V before the key window sees
/// them, and an app's menu glue reports them in the main window's input. While this is `Some`,
/// the dock area moves the keys a text field acts on (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z, ⌘ with the
/// arrows, ⌘Backspace and ⌘Delete, and `Copy`, `Cut` and `Paste` events) from the main window's
/// input to that window's, as [`send_keys`] does; the app's other command keys stay with the
/// main window. A menu that runs commands itself instead of reporting keys can send the text
/// keys to this window with [`send_keys`]. An app whose own `raw_input_hook` turns the main
/// window's ⌘V into `ViewportCommand::RequestPaste` for its text fields skips that while this
/// is `Some`, so the field's window pastes instead.
pub fn typing_window(ctx: &Context) -> Option<egui::ViewportId> {
#[cfg(feature = "native-windows")]
return super::render::typing_window(ctx);
#[cfg(not(feature = "native-windows"))]
{
let _ = ctx;
None
}
}
/// Give `events` to the native window `viewport` at the start of its next input, and repaint
/// it (feature `native-windows`; elsewhere it does nothing). A text field copies and pastes on
/// egui's `Copy`, `Cut` and `Paste` events, not on key presses, so ⌘C and ⌘X presses arrive as
/// `Copy` and `Cut`, and ⌘V asks the window for the clipboard text
/// (`ViewportCommand::RequestPaste`), which arrives as `Paste`. Other events arrive as they are.
/// See [`typing_window`].
pub fn send_keys(ctx: &Context, viewport: egui::ViewportId, events: Vec<egui::Event>) {
#[cfg(feature = "native-windows")]
super::render::send_keys(ctx, viewport, events);
#[cfg(not(feature = "native-windows"))]
let _ = (ctx, viewport, events);
}
/// One mutable application adapter owns bodies, menus, shaped text and per-panel policy,
/// avoiding competing mutable closures. Logical labels are used for tab accessibility.
/// [`DockArea::show_content`] reads every policy from here; the closures passed to the older