From ba00e9fd9e951bb5dc4593ec3c4e1d55e48b2b8a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:43:18 +0000 Subject: [PATCH] craft-ui: end drags and resizes on a focus loss, not while a window reports no focus egui-winit starts RawInput::focused false and only a winit Focused event sets it. A window that egui never heard was focused cancelled every tab, group and window drag and every resize on its first frame. Gestures now end on a focused-to-unfocused transition (or Escape); a window that never reported focus still drags while the pointer is pressed in it. --- crates/ui/src/docking/chrome.rs | 4 +- crates/ui/src/docking/dock_tests.rs | 43 +++++++++++++++- crates/ui/src/docking/native_tests.rs | 13 +++++ crates/ui/src/docking/render/cross.rs | 8 +-- crates/ui/src/docking/render/floating.rs | 3 +- crates/ui/src/docking/render/native.rs | 4 +- crates/ui/src/docking/render/preview.rs | 4 +- crates/ui/src/focus.rs | 64 ++++++++++++++++++++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/panes/mod.rs | 2 +- 10 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 crates/ui/src/focus.rs diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 2933ffd0..724df73f 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -180,7 +180,7 @@ impl<'a> FloatingFrame<'a> { let mut started = None; let mut responses = Vec::new(); let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; - let canceled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx()); for (name, handle, direction) in handles(rect) { if !super::targets::usable(handle) { continue; @@ -409,7 +409,7 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt } let (start, origin) = ctx.data(|data| data.get_temp::<(Rect, Pos2)>(key))?; let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key)); - if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { forget(); return Some(MoveStep::Canceled(start)); } diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 7819667f..265ae188 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -84,7 +84,23 @@ impl Driver { } fn with(layout: Layout, content: TestContent, right_to_left: bool) -> Self { - let mut driver = Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }; + Self::start(Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }) + } + + /// A window egui never heard has focus (macOS can make it key without a `Focused` event). + fn never_focused(layout: Layout) -> Self { + Self::start(Self { + ctx: Context::default(), + layout, + content: TestContent::default(), + focused: false, + right_to_left: false, + shapes: Vec::new(), + time: 0.0, + }) + } + + fn start(mut driver: Self) -> Self { // Floating areas settle after their sizing pass. for _ in 0..3 { driver.step(vec![]); @@ -515,6 +531,31 @@ fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel( assert_eq!(d.layout, layout); } +#[test] +fn a_window_that_never_reported_focus_drags_tabs_and_losing_focus_cancels_a_drag() { + let mut layout = docked(Side::Start, &[&[&[2]], &[&[3, 4]]]); + if let Some(Node::Split { size, .. }) = &mut layout.root { + *size = SplitSize::FixedFirst(480.0); + } + let to = pos2(484.0, 300.0); + let mut d = Driver::never_focused(layout.clone()); + let out = d.drag(d.tab(3).center(), to); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]); + + // Focus that arrives during the drag and goes again ends it. + let mut d = Driver::never_focused(layout.clone()); + d.hold(d.tab(3).center(), to); + d.focused = true; + d.step(vec![Event::WindowFocused(true)]); + assert!(is_dragging::(&d.ctx, Id::new(AREA))); + d.focused = false; + d.step(vec![Event::WindowFocused(false)]); + let out = d.step(vec![button(to, false)]); + assert!(out.actions.is_empty(), "{out:?}"); + assert_eq!(d.layout, layout); + assert!(!is_dragging::(&d.ctx, Id::new(AREA))); +} + #[test] fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() { let widths = render::fit_widths(&[(600.0, Some(40.0)), (600.0, Some(40.0)), (48.0, None)], 800.0); diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 89269c66..db123d72 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -940,6 +940,19 @@ fn escape_in_a_focused_native_window_cancels_a_main_window_drag() { assert!(!super::is_dragging::(&d.ctx, d.id)); } +#[test] +fn a_main_window_without_focus_still_drags_a_tab_into_another_group() { + let mut d = driver(layout(false)); + d.host().focus = None; + d.settle(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let over = d.hit(&Hit::Tab(3)).unwrap().center(); + d.hold(tab, over); + assert!(super::is_dragging::(&d.ctx, d.id)); + let out = d.release(over); + assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 1, .. })), "{:?}", out.actions); +} + #[test] fn focus_moving_to_a_native_window_mid_drag_keeps_the_drag() { let mut d = driver(two_windows()); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 3a17f307..b30e058c 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -81,8 +81,8 @@ enum Dest { pub(in crate::docking) struct Cross

{ /// Viewports are native: drags cross windows. pub on: bool, - /// One of the application's windows has focus. - pub focused: bool, + /// The application's windows lost focus this pass: one had it on the last pass, none now. + pub focus_lost: bool, /// The main window's content on the desktop, where known. pub main: Option, /// Shown native windows' desktop rects, front first. @@ -109,7 +109,7 @@ impl

Default for Cross

{ fn default() -> Self { Self { on: false, - focused: true, + focus_lost: false, main: None, windows: Vec::new(), surfaces: Vec::new(), @@ -432,7 +432,7 @@ impl Renderer<'_, P> { pointer: at, down: pointer.down, released: pointer.released, - focused: self.cross.focused, + focused: !self.cross.focus_lost, gone: false, escape: pointer.escape, command: pointer.command, diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index ceaecb95..b1a2a419 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -153,7 +153,8 @@ impl Renderer<'_, P> { && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) && drag.gesture == GroupGesture::Window && let Some(pointer) = ui.ctx().pointer_interact_pos() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + && !ui.input(|i| i.key_pressed(Key::Escape)) + && !crate::focus::lost(ui.ctx()) { // From the press origin, so fractional pointer steps do not accumulate rounding. let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 0279e28c..952027cd 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -251,7 +251,7 @@ impl Renderer<'_, P> { let focused = i.focused || infos().any(|v| v.focused == Some(true)) || infos().all(|v| v.focused.is_none()); (focused, i.time) }); - self.cross.focused = focused; + self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); let key = self.id.with("native-visibility"); let (last, was) = ctx.data(|d| d.get_temp::<(f64, bool)>(key)).unwrap_or((now, true)); let last = if focused { now } else { last }; @@ -525,7 +525,7 @@ impl Renderer<'_, P> { ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(to - frame)); r.cross.moved = Some(to); }; - if pointer.escape || !self.cross.focused { + if pointer.escape || self.cross.focus_lost { if pointer.escape { move_to(self, drag.start); ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 0bf8da81..b746c8e6 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -27,6 +27,7 @@ pub(super) struct DragInput { pub pointer: Option, pub down: bool, pub released: bool, + /// The window kept its focus: false on the pass it loses it (see [`crate::focus`]). pub focused: bool, /// The pointer left the window. pub gone: bool, @@ -41,11 +42,12 @@ impl DragInput { /// This window's own input. fn of(ctx: &Context) -> Self { let command = float_only(ctx); + let focus_lost = crate::focus::lost(ctx); ctx.input(|i| Self { pointer: i.pointer.interact_pos().filter(|p| p.is_finite()), down: i.pointer.primary_down(), released: i.pointer.primary_released(), - focused: i.focused, + focused: !focus_lost, gone: !i.pointer.has_pointer(), escape: i.key_pressed(Key::Escape), command, diff --git a/crates/ui/src/focus.rs b/crates/ui/src/focus.rs new file mode 100644 index 00000000..006f8f82 --- /dev/null +++ b/crates/ui/src/focus.rs @@ -0,0 +1,64 @@ +//! Window focus as pointer gestures read it. +//! +//! A drag or resize ends when its window loses focus, not while the window merely reports no +//! focus. egui-winit starts `RawInput::focused` false and only a winit `Focused` event sets it +//! (on macOS to `has_focus()` at the time the event is read), so a window can take pointer +//! input for a long time while egui reports it unfocused, at first launch until the user +//! switches to another application and back. Gestures in such a window still run. + +use egui::{Context, Id}; + +#[derive(Clone, Copy)] +struct Seen { + pass: u64, + before: bool, + now: bool, +} + +/// Whether `now` is false and was true on the previous pass, as recorded under `key`. With no +/// record from the previous pass there is no transition, so a gesture asks on every pass it +/// runs. Asking again in the same pass gives the same answer. +pub(crate) fn turned_false(ctx: &Context, key: Id, now: bool) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + let seen = match d.get_temp::(key) { + Some(seen) if seen.pass == pass => seen, + Some(seen) if seen.pass + 1 == pass => Seen { pass, before: seen.now, now }, + _ => Seen { pass, before: now, now }, + }; + d.insert_temp(key, seen); + seen.before && !seen.now + }) +} + +/// Whether the window being drawn lost focus this pass: focused on the previous pass, not now. +pub(crate) fn lost(ctx: &Context) -> bool { + let focused = ctx.input(|i| i.focused); + turned_false(ctx, Id::new("craft-ui-window-focus").with(ctx.viewport_id()), focused) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::RawInput; + + fn pass(ctx: &Context, focused: bool) -> bool { + let mut lost_now = false; + let mut out = ctx.run_ui(RawInput { focused, ..Default::default() }, |ui| { + lost_now = lost(ui.ctx()); + assert_eq!(lost(ui.ctx()), lost_now, "asking twice in a pass"); + }); + out.textures_delta.clear(); + lost_now + } + + #[test] + fn only_a_focused_to_unfocused_pass_is_a_loss() { + let ctx = Context::default(); + assert!(!pass(&ctx, false), "a window that never had focus has not lost it"); + assert!(!pass(&ctx, false)); + assert!(!pass(&ctx, true)); + assert!(pass(&ctx, false)); + assert!(!pass(&ctx, false), "the loss is one pass"); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index f54d1bbc..d1a30342 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -7,6 +7,7 @@ pub mod buttons; pub mod docking; +mod focus; pub mod icons; pub mod layout; pub mod panes; diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 75b20ddd..b50829f1 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -559,7 +559,7 @@ impl PaneHost { memory.drag_out = Some(DragOut { pane: s.id, canceled: false }); } let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; - if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { drag.canceled = true; } let near = rect.expand(DOCK_SNAP);