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.
This commit is contained in:
aiongg committed 2026-10-24 03:45:08 +00:00
1 parent 09e14347d3
commit ba00e9fd9e
10 files changed
+134 -12

No files matched your search

+2 -2
View File
@@ -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));
}
+42 -1
View File
@@ -84,7 +84,23 @@ impl Driver {
}
fn with(layout: Layout<u32>, 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<u32>) -> 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::<u32>(&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::<u32>(&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);
+13
View File
@@ -940,6 +940,19 @@ fn escape_in_a_focused_native_window_cancels_a_main_window_drag() {
assert!(!super::is_dragging::<u32>(&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::<u32>(&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());
+4 -4
View File
@@ -81,8 +81,8 @@ enum Dest {
pub(in crate::docking) struct Cross<P> {
/// 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<Rect>,
/// Shown native windows' desktop rects, front first.
@@ -109,7 +109,7 @@ impl<P> Default for Cross<P> {
fn default() -> Self {
Self {
on: false,
focused: true,
focus_lost: false,
main: None,
windows: Vec::new(),
surfaces: Vec::new(),
@@ -432,7 +432,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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,
+2 -1
View File
@@ -153,7 +153,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(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);
+2 -2
View File
@@ -251,7 +251,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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));
+3 -1
View File
@@ -27,6 +27,7 @@ pub(super) struct DragInput {
pub pointer: Option<Pos2>,
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,
+64
View File
@@ -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::<Seen>(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");
}
}
+1
View File
@@ -7,6 +7,7 @@
pub mod buttons;
pub mod docking;
mod focus;
pub mod icons;
pub mod layout;
pub mod panes;
+1 -1
View File
@@ -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);