diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 9b0273d9d..1f9f5d64a 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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, diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 19e7697f4..ace6b5538 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -27,6 +27,8 @@ struct Content { text: Option, 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, /// 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, + /// Key and clipboard events panel 1 saw in the main window, and the text field's panel in + /// its own window. + main_keys: Vec, + field_keys: Vec, } /// How a test's app drag source marks its drag. @@ -76,6 +82,9 @@ impl DockContent 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 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::>() + }); + (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 { + 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, 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(); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 20ce2130d..9ba314c5c 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -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 diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index d9aab434d..cb75b001f 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -584,6 +584,9 @@ impl 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, + typing_next: std::collections::HashSet, + /// 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)>, } pub(in crate::docking) type InputHook = Box; @@ -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, viewports: &egui::ViewportIdMap) -> Option { + 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) { + 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 { + // 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) { + 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) { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 70a6fb9cb..79d0666cc 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -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 { + #[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) { + #[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