Merge #244: focus-loss gestures, macOS native resize, tunable ×

This commit is contained in:
aiongg committed 2026-10-24 03:54:18 +00:00
commit 10fc07fd2b
12 files changed
+384 -179

No files matched your search

+19 -6
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;
@@ -282,7 +282,7 @@ pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) ->
ui.painter().layout_job(job)
}
/// A title bar: an optional title, an optional ▾ menu button and an optional ×, 10 pt
/// A title bar: an optional title, an optional ▾ menu button and an optional ×, by default 10 pt
/// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses
/// clicks and drags; double-clicks are the caller's.
pub struct TitleBar<'a> {
@@ -291,6 +291,8 @@ pub struct TitleBar<'a> {
title: Option<WidgetText>,
menu: Option<&'a str>,
close: Option<&'a str>,
/// The ×'s hit area and glyph, where the caller places them.
close_at: Option<(Rect, Rect)>,
movable: bool,
}
@@ -306,7 +308,7 @@ impl<'a> TitleBar<'a> {
/// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`.
/// `move_label` names the bar for accessibility.
pub fn new(id: Id, move_label: &'a str) -> Self {
Self { id, move_label, title: None, menu: None, close: None, movable: true }
Self { id, move_label, title: None, menu: None, close: None, close_at: None, movable: true }
}
/// Text at the leading edge, cut with '…' to fit.
@@ -327,6 +329,14 @@ impl<'a> TitleBar<'a> {
self
}
/// Place the × at `glyph`, taking clicks in `hit`: rects as drawn, already mirrored in a
/// right-to-left layout. By default the × is a square the bar's height, its center 10 pt
/// in from the trailing edge.
pub fn close_at(mut self, hit: Rect, glyph: Rect) -> Self {
self.close_at = Some((hit, glyph));
self
}
/// Whether the bar shows the grab cursor. It senses drags either way.
pub fn movable(mut self, movable: bool) -> Self {
self.movable = movable;
@@ -345,7 +355,10 @@ impl<'a> TitleBar<'a> {
r
}
};
let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)));
let close_rect = self.close.map(|_| match self.close_at {
Some((hit, _)) => mirror(hit),
None => Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)),
});
let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0);
let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom())));
let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left());
@@ -376,7 +389,7 @@ impl<'a> TitleBar<'a> {
});
let close = close_rect.zip(self.close).map(|(close_rect, label)| {
let (response, stroke) = button(ui, close_rect, "close", label);
let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0)));
let r = self.close_at.map_or_else(|| Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))), |(_, glyph)| glyph);
painter.line_segment([r.left_top(), r.right_bottom()], stroke);
painter.line_segment([r.right_top(), r.left_bottom()], stroke);
response
@@ -409,7 +422,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);
+77 -34
View File
@@ -3,7 +3,7 @@
use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2};
use super::render::{DragPointer, WindowDrag, floats_shown, resize_bands, resized};
use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands};
use super::testing::DockDriver;
use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target};
use crate::layout::{SplitAxis, SplitSize};
@@ -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());
@@ -1000,10 +1013,12 @@ fn a_drag_ends_when_its_native_window_or_panel_goes() {
}
/// Press at `from` in the native window of panel 5, move the pointer by `by` on the desktop
/// and release. Returns the window's OS rect and the driver.
fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver<u32, Content>) {
/// and release. Returns the window's OS rect, the resize the window handed to the window
/// system, and the driver.
fn press_and_drag(from: Pos2, by: Vec2) -> (Rect, Option<egui::ResizeDirection>, DockDriver<u32, Content>) {
let mut d = driver(layout(true));
let id = window(&d, 5).id;
let viewport = d.viewport(id);
let press = os_window(&d, 5).map_or(Pos2::ZERO, |(rect, _)| rect.min) + from.to_vec2();
d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]);
// The pointer is where it was moved on the desktop, wherever the window is now.
@@ -1015,53 +1030,57 @@ fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver<u32, Content>) {
let pos = local(&d, by);
d.window_step(id, vec![Event::PointerMoved(pos), button(pos, false)]);
d.step(vec![]);
(os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), d)
let resize = d.host().commands.iter().find_map(|(v, c)| match c {
ViewportCommand::BeginResize(direction) if *v == viewport => Some(*direction),
_ => None,
});
(os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), resize, d)
}
#[test]
fn the_bars_close_button_wins_over_the_resize_bands() {
use egui::ResizeDirection as D;
let mut d = driver(layout(true));
let id = window(&d, 5).id;
let viewport = d.viewport(id);
// The × of the 240 pt wide window spans 217..235 across and the whole bar down. Its top
// The × of the 240 pt wide window spans 215..233 across and the whole bar down. Its top
// right is next to the window's top-right corner, but it closes the window.
let out = d.window_click(id, pos2(234.0, 1.0));
let out = d.window_click(id, pos2(232.0, 1.0));
assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions);
assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::InnerSize(_))), "not resized");
assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::BeginResize(_))), "not resized");
// Beside it, at the window's edges, the bands resize the window from (400, 130), 240 by
// 300, keeping the opposite edges.
let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
let (resized, d) = resize_by(pos2(238.0, 2.0), vec2(20.0, 20.0));
assert_eq!(resized, rect(400.0, 150.0, 660.0, 430.0), "the top-right corner");
assert_eq!(window(&d, 5).native, Some([400.0, 150.0, 260.0, 280.0]), "the layout follows");
assert_eq!(resize_by(pos2(238.0, 15.0), vec2(20.0, 20.0)).0, rect(400.0, 130.0, 660.0, 430.0), "the right edge beside the ×");
assert_eq!(resize_by(pos2(120.0, 1.0), vec2(20.0, -20.0)).0, rect(400.0, 110.0, 640.0, 430.0), "the top edge above the bar");
assert_eq!(resize_by(pos2(2.0, 298.0), vec2(-20.0, 20.0)).0, rect(380.0, 130.0, 640.0, 450.0), "the bottom-left corner");
assert_eq!(resize_by(pos2(2.0, 150.0), vec2(200.0, 0.0)).0, rect(480.0, 130.0, 640.0, 430.0), "no narrower than 160");
// Beside it, at the window's edges, the bands hand the resize to the window system.
let resize = |from: Pos2| press_and_drag(from, vec2(20.0, 20.0)).1;
assert_eq!(resize(pos2(238.0, 2.0)), Some(D::NorthEast), "the top-right corner");
assert_eq!(resize(pos2(237.0, 15.0)), Some(D::NorthEast), "the corner beside the ×");
assert_eq!(resize(pos2(237.0, 100.0)), Some(D::East), "the right edge");
assert_eq!(resize(pos2(120.0, 1.0)), Some(D::North), "the top edge above the bar");
assert_eq!(resize(pos2(2.0, 298.0)), Some(D::SouthWest), "the bottom-left corner");
// Inside a corner's L, the bar moves the window.
let (moved, _) = resize_by(pos2(10.0, 10.0), vec2(20.0, 20.0));
assert_eq!(moved, rect(420.0, 150.0, 660.0, 450.0));
let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
let (moved, resize, _) = press_and_drag(pos2(10.0, 10.0), vec2(20.0, 20.0));
assert_eq!((moved, resize), (rect(420.0, 150.0, 660.0, 450.0), None));
}
#[test]
fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0));
let close = Rect::from_center_size(pos2(226.0, 9.0), vec2(18.0, 18.0));
let close = Rect::from_center_size(pos2(224.0, 9.0), vec2(18.0, 18.0));
let bands = resize_bands(rect, &[close]);
let at = |p: Pos2| bands.iter().find(|(_, band)| band.contains(p)).map(|(direction, _)| *direction);
for (_, band) in &bands {
assert!(!band.intersect(close).is_positive(), "{band:?} covers the ×");
assert!(band.width().min(band.height()) <= 4.0, "{band:?} is deeper than an edge band");
assert!(band.width().min(band.height()) <= 6.0, "{band:?} is deeper than an edge band");
}
// Each edge resizes along its whole length, outside the ×, from 12 pt corners.
// Each edge resizes along its whole length, outside the ×, from 16 pt corners.
use egui::ResizeDirection as D;
// Which of an edge's three parts `v` is in: the first corner, the edge, the last corner.
let part = |v: f32, length: f32| usize::from(v >= 12.0) + usize::from(v > length - 12.0);
let part = |v: f32, length: f32| usize::from(v >= 16.0) + usize::from(v > length - 16.0);
for i in 0..240 {
let x = i as f32 + 0.5;
let top = [D::NorthWest, D::North, D::NorthEast][part(x, 240.0)];
assert_eq!(at(pos2(x, 0.5)), (!close.contains(pos2(x, 0.5))).then_some(top), "top at {x}");
assert_eq!(at(pos2(x, 5.5)), (!close.contains(pos2(x, 5.5))).then_some(top), "6 pt down at {x}");
assert_eq!(at(pos2(x, 299.5)), Some([D::SouthWest, D::South, D::SouthEast][part(x, 240.0)]), "bottom at {x}");
}
for i in 0..300 {
@@ -1069,20 +1088,44 @@ fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() {
assert_eq!(at(pos2(0.5, y)), Some([D::NorthWest, D::West, D::SouthWest][part(y, 300.0)]), "left at {y}");
assert_eq!(at(pos2(239.5, y)), Some([D::NorthEast, D::East, D::SouthEast][part(y, 300.0)]), "right at {y}");
}
assert_eq!(at(pos2(6.0, 6.0)), None, "a corner is an L along its edges");
assert_eq!(at(pos2(120.0, 4.5)), None, "the bar below the top band");
assert_eq!(at(pos2(10.0, 10.0)), None, "a corner is an L along its edges");
assert_eq!(at(pos2(120.0, 6.5)), None, "the bar below the top band");
}
#[test]
fn a_manual_resize_keeps_the_opposite_edges_and_the_minimum_size() {
use egui::ResizeDirection as D;
let (start, min) = ([100.0, 50.0, 300.0, 200.0], [160.0, 100.0]);
// Right edge 400, bottom 250.
assert_eq!(resized(start, D::NorthWest, vec2(-20.0, -10.0), min), [80.0, 40.0, 320.0, 210.0]);
assert_eq!(resized(start, D::East, vec2(30.0, 99.0), min), [100.0, 50.0, 330.0, 200.0]);
assert_eq!(resized(start, D::South, vec2(30.0, -20.0), min), [100.0, 50.0, 300.0, 180.0]);
assert_eq!(resized(start, D::SouthWest, vec2(500.0, -500.0), min), [240.0, 50.0, 160.0, 100.0]);
assert_eq!(resized(start, D::NorthEast, vec2(-500.0, 500.0), min), [100.0, 150.0, 160.0, 100.0]);
fn on_macos_the_close_glyph_sits_by_the_corner_and_its_hit_area_reaches_away_from_it() {
use crate::docking::chrome::{ChromeStyle, TitleBar};
use crate::docking::placement::BAR_HEIGHT;
let ctx = egui::Context::default();
let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0));
let bar = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT));
for right_to_left in [false, true] {
let (hit, glyph) = close_rects(bar, MACOS_CLOSE, right_to_left);
// Distances from the corner the × sits by: across from the trailing edge, and down.
let across = |x: f32| if right_to_left { x - full.left() } else { full.right() - x };
let (glyph_near, glyph_far) = (across(glyph.right()).min(across(glyph.left())), across(glyph.right()).max(across(glyph.left())));
assert_eq!((glyph_near, glyph.top(), glyph.width(), glyph.height()), (8.0, 4.5, 9.0, 9.0), "the glyph by the corner");
let (hit_near, hit_far) = (across(hit.right()).min(across(hit.left())), across(hit.right()).max(across(hit.left())));
assert_eq!(hit_near, glyph_near, "the hit area reaches no closer to the corner than the glyph");
assert_eq!(hit_far, glyph_far + 10.0, "the hit area reaches away from the corner");
assert_eq!((hit.top(), hit.bottom()), (glyph.top(), BAR_HEIGHT), "the hit area reaches down to the bar's bottom, not below");
// AppKit's corner area takes the presses nearest the corner; the rest reach the ×.
let [top_left, top_right, ..] = appkit_resize_areas(full);
let corner = if right_to_left { top_left } else { top_right };
assert_eq!(corner.width(), APPKIT_CORNER);
assert!(hit_far - APPKIT_CORNER >= 10.0, "{hit:?} has too little room outside {corner:?}");
let mut shown = None;
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| {
let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) };
let response = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").close("Close").close_at(hit, glyph).show(ui, bar, &style);
shown = response.close.map(|close| (close.rect, response.bar.rect));
});
output.textures_delta.clear();
let (close, drag) = shown.unwrap_or((Rect::NOTHING, Rect::NOTHING));
assert_eq!(close, hit, "the bar takes clicks for the × in its hit area");
assert!(!drag.intersect(hit).is_positive(), "the bar's drag area {drag:?} leaves the × alone");
}
}
/// [`layout`] built anew, its window with id 7 when `with_id`.
+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);
+4 -1
View File
@@ -15,7 +15,10 @@ mod iconic;
#[cfg(feature = "native-windows")]
mod native;
#[cfg(all(test, feature = "native-windows"))]
pub(in crate::docking) use {cross::DragPointer, native::WindowDrag, native::floats_shown, native::resize_bands, native::resized};
pub(in crate::docking) use {
cross::DragPointer,
native::{APPKIT_CORNER, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands},
};
/// The viewport of the native window `window` of the dock area `area`.
#[cfg(feature = "native-windows")]
+147 -119
View File
@@ -26,14 +26,66 @@ const MIN_SIZE: [f32; 2] = [160.0, 100.0];
const HIDE_DELAY: f64 = 0.25;
/// How long reports of a native window's place may lag a move the dock area commanded.
const MOVE_WAIT: f64 = 0.5;
/// How far the resize bands reach into a native window from its edges.
const EDGE: f32 = 4.0;
/// How far the resize bands reach into a native window from its edges, where the window has
/// no frame of its own to resize from.
const EDGE: f32 = 6.0;
/// How far a corner's grab runs along each of its two edges.
const CORNER: f32 = 12.0;
/// The window system runs a resize from our bands (`BeginResize`). winit cannot on macOS, so
/// there the window follows the pointer by `InnerSize` and `OuterPosition`. Unit tests take
/// that path on every platform, since the test host carries it out.
const SYSTEM_RESIZE: bool = !cfg!(any(target_os = "macos", test));
const CORNER: f32 = 16.0;
/// macOS keeps the window's frame, and AppKit resizes the window from its edges and corners.
/// Elsewhere the window is undecorated and resizes from our bands, which hand the resize to
/// the window system (`BeginResize`).
const OS_RESIZES: bool = cfg!(target_os = "macos");
/// How far AppKit's corner resize area reaches into a titled, resizable window along each
/// edge. AppKit takes a press there before the content sees it, and shows its resize cursor.
/// Measured on macOS 26 as a 19 pt square at each corner, 62 % of which would lie inside a
/// square-cornered window: about 15 pt inside and 4 pt outside
/// (<https://noheger.at/blog/2026/01/11/the-struggle-of-resizing-windows-on-macos-tahoe/>).
/// There is no published measurement for earlier releases.
pub(in crate::docking) const APPKIT_CORNER: f32 = 15.0;
/// How far AppKit's edge resize area reaches into the content between the corners: a few
/// points; most of it lies outside the window.
const APPKIT_EDGE: f32 = 4.0;
/// Where the × of a native window's bar sits and where it takes clicks, in points from the
/// window's top and trailing edges, mirrored in a right-to-left layout.
#[derive(Clone, Copy, Debug)]
pub(in crate::docking) struct CloseLayout {
/// From the window's top edge to the top of the glyph.
pub glyph_top: f32,
/// From the window's trailing edge to the glyph's trailing side.
pub glyph_trailing: f32,
/// Width and height of the glyph.
pub glyph_size: f32,
/// How far the hit area reaches past the glyph to the left (to the right when mirrored).
pub hit_left: f32,
/// How far the hit area reaches past the glyph to the right (to the left when mirrored).
pub hit_right: f32,
/// How far the hit area reaches above the glyph.
pub hit_up: f32,
/// How far the hit area reaches below the glyph. Past the bar's bottom it takes clicks
/// from the tab strip under the bar.
pub hit_down: f32,
}
/// The × on macOS, where macOS puts the close button of its own windows: centered in the
/// 18 pt bar, its trailing side 8 pt in. AppKit's corner resize area ([`APPKIT_CORNER`])
/// covers its trailing half and takes presses there, so the hit area reaches away from the
/// corner: left of the glyph, and down to the bar's bottom.
pub(in crate::docking) const MACOS_CLOSE: CloseLayout =
CloseLayout { glyph_top: 4.5, glyph_trailing: 8.0, glyph_size: 9.0, hit_left: 10.0, hit_right: 0.0, hit_up: 0.0, hit_down: 4.5 };
/// The × elsewhere: an 18 pt square the height of the bar, its trailing side 7 pt in so the
/// side's resize band ([`EDGE`]) and the corner's grab run down beside it, with the glyph at
/// its center.
pub(in crate::docking) const OTHER_CLOSE: CloseLayout =
CloseLayout { glyph_top: 4.5, glyph_trailing: 11.5, glyph_size: 9.0, hit_left: 4.5, hit_right: 4.5, hit_up: 4.5, hit_down: 4.5 };
/// Whether native windows outline their ×'s hit area, and on macOS AppKit's resize areas:
/// `CRAFT_DOCK_DEBUG_CLOSE=1`, read once.
fn debug_close() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("CRAFT_DOCK_DEBUG_CLOSE").is_ok_and(|v| v == "1"))
}
/// A drag of a native window's bar, in the context's data under the area's `"window-drag"`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -51,21 +103,6 @@ pub(in crate::docking) struct WindowDrag {
pub docks: bool,
}
/// A resize of a native window from one of its bands where the window system does not run it
/// ([`SYSTEM_RESIZE`]), in the context's data under the area's `"window-resize"`.
#[derive(Clone, Copy, Debug)]
struct WindowResize {
/// [`Floating::id`] of the resized window.
window: u64,
direction: ResizeDirection,
/// Desktop rect of the window's content when the drag began.
start: [f32; 4],
/// Desktop point where the pointer pressed.
press: Pos2,
/// Desktop rect of the window's content as last commanded.
to: [f32; 4],
}
/// What the dock area knows of a shown native window's place, in the context's data under
/// the area's `("native-place", viewport)`. Rects are desktop rects of the window's content.
#[derive(Clone, Copy, Debug)]
@@ -163,15 +200,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
#[cfg(debug_assertions)]
self.warn_rebuilt(ctx, &natives);
self.escape_elsewhere(ctx, None);
// A window closed or docked during its own bar drag or resize ends it.
let shown = |window: u64| natives.iter().any(|(w, ..)| w.id == window);
let (key, resize_key) = (self.id.with("window-drag"), self.id.with("window-resize"));
if ctx.data(|d| d.get_temp::<WindowDrag>(key)).is_some_and(|d| !shown(d.window)) {
// A window closed or docked during its own bar drag ends the drag.
let key = self.id.with("window-drag");
if ctx.data(|d| d.get_temp::<WindowDrag>(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) {
ctx.data_mut(|d| d.remove::<WindowDrag>(key));
}
if ctx.data(|d| d.get_temp::<WindowResize>(resize_key)).is_some_and(|r| !shown(r.window)) {
ctx.data_mut(|d| d.remove::<WindowResize>(resize_key));
}
self.map_desktop(ctx, &natives, visibility.0);
// The main window owns drags of the panels it draws.
let id = self.id;
@@ -257,7 +290,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 };
@@ -317,16 +350,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
.with_min_inner_size(MIN_SIZE)
.with_window_level(WindowLevel::AlwaysOnTop)
.with_taskbar(false);
// The bar replaces the OS title bar. macOS keeps the frame for its shadow and rounded
// corners, but not its resizing: AppKit's resize areas reach into the content over the
// bar's × and take the pointer from it. The bands resize the window instead.
// The bar replaces the OS title bar. macOS keeps the frame, with its title bar and
// buttons hidden: AppKit resizes a movable window from its edges and corners with its
// own cursors, and draws its rounded corners and standard shadow.
#[cfg(target_os = "macos")]
let builder = builder
.with_titlebar_shown(false)
.with_title_shown(false)
.with_fullsize_content_view(true)
.with_titlebar_buttons_shown(false)
.with_resizable(false);
.with_resizable(window.panels().all(|p| self.permissions(p).movable));
#[cfg(not(target_os = "macos"))]
let builder = builder.with_decorations(false);
if toggled || (info.is_none() && !visible) {
@@ -342,14 +375,20 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let buttons = self.native_bar(ui, window);
let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max);
self.floating_body(ui, window, body, full);
if debug_close() {
debug_outlines(ui, full, &buttons);
}
// An embedded fallback reads the main window's input: its close request, place,
// keys and resizes are not this window's.
if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) {
return Report::default();
}
if window.panels().all(|p| self.permissions(p).movable) {
self.resize_edges(ui, window.id, full, &buttons);
self.follow_window_resize(ui, window.id);
if OS_RESIZES {
leave_cursor_to_os(ui, full);
} else {
resize_edges(ui, self.id.with(("native-edges", window.id)), full, &buttons);
}
}
let mut surface = Surface {
window: window.id,
@@ -433,16 +472,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let style = ChromeStyle::from_dock(self.style);
let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height())));
ui.painter().rect_filled(rect, 0.0, style.title_background);
// The buttons keep clear of the side's resize band, so the corner's grab runs down
// beside them.
let rect = if style.right_to_left { rect.with_min_x(rect.left() + EDGE) } else { rect.with_max_x(rect.right() - EDGE) };
let (close_hit, close_glyph) = close_rects(rect, if OS_RESIZES { MACOS_CLOSE } else { OTHER_CLOSE }, style.right_to_left);
let movable = window.panels().all(|p| self.permissions(p).movable);
let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable);
let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone());
let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default();
let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable);
// On macOS the bar keeps the arrow, as native title bars do: AppKit's resize areas
// cover its ends and top edge, and a cursor set over them flickers with AppKit's.
let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable && !OS_RESIZES);
if closable {
bar = bar.close(&close_label);
bar = bar.close(&close_label).close_at(close_hit, close_glyph);
}
let response = bar.show(ui, rect, &style);
if response.close.as_ref().is_some_and(|close| close.clicked()) {
@@ -528,7 +567,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));
@@ -559,62 +598,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.cross.release = Some((drag, pointer));
}
/// The resize bands of a movable native window. Drawn after its content, so they take the
/// pointer at its edges; the bar's `buttons` are left out.
fn resize_edges(&mut self, ui: &Ui, window: u64, full: Rect, buttons: &[Rect]) {
let id = self.id.with(("native-edges", window));
for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() {
let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction));
if response.drag_started() {
self.start_window_resize(ui, window, direction);
}
}
}
/// Start a resize: by the window system where it runs one, else by following the pointer
/// from the place the window system reports.
fn start_window_resize(&mut self, ui: &Ui, window: u64, direction: ResizeDirection) {
let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos()));
let content = ui.input(|i| placed(i.viewport()));
match (SYSTEM_RESIZE, content, press) {
(false, Some(start), Some(press)) => {
let resize = WindowResize { window, direction, start, press: pos2(start[0], start[1]) + press.to_vec2(), to: start };
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-resize"), resize));
}
_ => ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)),
}
}
/// Size and move the window while the button is held, the edges opposite the grabbed band
/// in place, no smaller than [`MIN_SIZE`]. Released, a window left with too little of its
/// bar on a monitor goes back onto the nearest, as after a bar drag.
fn follow_window_resize(&mut self, ui: &Ui, window: u64) {
let key = self.id.with("window-resize");
let Some(mut resize) = ui.ctx().data(|d| d.get_temp::<WindowResize>(key)).filter(|r| r.window == window) else { return };
ui.ctx().set_cursor_icon(resize_cursor(resize.direction));
let content = ui.input(|i| content_of(i.viewport()));
let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame);
// As in a bar drag: a pointer move is relative to where the window system reports the
// window now.
let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None }));
let mut to = resize.to;
if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) {
to = resized(resize.start, resize.direction, origin + local.to_vec2() - resize.press, MIN_SIZE);
}
let over = !ui.input(|i| i.pointer.primary_down()) || !self.cross.focused;
if over {
to = kept_reachable(to, &self.monitors);
ui.ctx().data_mut(|d| d.remove::<WindowResize>(key));
}
if differs(to, resize.to) {
place_window(ui.ctx(), ui.ctx().viewport_id(), to, frame);
resize.to = to;
if !over {
ui.ctx().data_mut(|d| d.insert_temp(key, resize));
}
}
}
/// Keys, text and clipboard events the window's widgets left over, for the application's
/// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own.
fn forward_events(&mut self, ui: &Ui) {
@@ -660,6 +643,72 @@ fn place_window(ctx: &Context, viewport: ViewportId, to: [f32; 4], frame: Vec2)
ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(to[0], to[1]) - frame));
}
/// The resize bands of a movable native window without a frame of its own. Drawn after its
/// content, so they take the pointer at its edges; the bar's `buttons` are left out.
fn resize_edges(ui: &Ui, id: Id, full: Rect, buttons: &[Rect]) {
for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() {
let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction));
if response.drag_started() {
ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction));
}
}
}
/// Over AppKit's resize areas of the window's content `full`, the default cursor whatever the
/// content asked for: AppKit shows its resize cursor there, and any other cursor we set fights
/// it.
fn leave_cursor_to_os(ui: &Ui, full: Rect) {
if ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| appkit_resize_areas(full).iter().any(|area| area.contains(p))) {
ui.ctx().set_cursor_icon(CursorIcon::Default);
}
}
/// AppKit's resize areas inside a window's content `rect`: a square at each corner and a strip
/// along each edge.
pub(in crate::docking) fn appkit_resize_areas(rect: Rect) -> [Rect; 8] {
let (c, e) = (Vec2::splat(APPKIT_CORNER), APPKIT_EDGE);
[
Rect::from_min_size(rect.left_top(), c),
Rect::from_min_size(rect.right_top() - vec2(c.x, 0.0), c),
Rect::from_min_size(rect.left_bottom() - vec2(0.0, c.y), c),
Rect::from_min_size(rect.right_bottom() - c, c),
rect.with_max_y(rect.top() + e),
rect.with_min_y(rect.bottom() - e),
rect.with_max_x(rect.left() + e),
rect.with_min_x(rect.right() - e),
]
}
/// Over a native window's content `full`: its bar's `buttons` outlined in red and, on macOS,
/// AppKit's resize areas in orange.
fn debug_outlines(ui: &Ui, full: Rect, buttons: &[Rect]) {
let stroke = |color| egui::Stroke::new(1.0, color);
for button in buttons {
ui.painter().rect_stroke(*button, 0.0, stroke(egui::Color32::RED), egui::StrokeKind::Inside);
}
if OS_RESIZES {
for area in appkit_resize_areas(full) {
ui.painter().rect_stroke(area, 0.0, stroke(egui::Color32::ORANGE), egui::StrokeKind::Inside);
}
}
}
/// The hit area and glyph of the × in a native window's bar `bar`, laid out by `layout` from
/// the bar's top and trailing edges.
pub(in crate::docking) fn close_rects(bar: Rect, layout: CloseLayout, right_to_left: bool) -> (Rect, Rect) {
let l = layout;
let glyph = Rect::from_min_size(pos2(bar.right() - l.glyph_trailing - l.glyph_size, bar.top() + l.glyph_top), Vec2::splat(l.glyph_size));
let hit = Rect::from_min_max(glyph.min - vec2(l.hit_left, l.hit_up), glyph.max + vec2(l.hit_right, l.hit_down));
let mirror = |r: Rect| {
if right_to_left {
Rect::from_x_y_ranges((bar.left() + bar.right() - r.right())..=(bar.left() + bar.right() - r.left()), r.y_range())
} else {
r
}
};
(mirror(hit), mirror(glyph))
}
/// The bands along the edges of a native window's content `rect` that resize it: an L at each
/// corner, [`EDGE`] deep and [`CORNER`] along both edges, and the edges between them. They
/// leave out the bar's `buttons`, which take their own clicks.
@@ -709,27 +758,6 @@ fn outside(rect: Rect, hole: Rect) -> Vec<Rect> {
.collect()
}
/// The desktop rect `start` with the edges `direction` names moved by `delta`, the opposite
/// edges kept, and no smaller than `min`.
pub(in crate::docking) fn resized(start: [f32; 4], direction: ResizeDirection, delta: Vec2, min: [f32; 2]) -> [f32; 4] {
use ResizeDirection as D;
let [x, y, w, h] = start;
let (mut left, mut top, mut right, mut bottom) = (x, y, x + w, y + h);
if matches!(direction, D::West | D::NorthWest | D::SouthWest) {
left = (left + delta.x).min(right - min[0]);
}
if matches!(direction, D::East | D::NorthEast | D::SouthEast) {
right = (right + delta.x).max(left + min[0]);
}
if matches!(direction, D::North | D::NorthWest | D::NorthEast) {
top = (top + delta.y).min(bottom - min[1]);
}
if matches!(direction, D::South | D::SouthWest | D::SouthEast) {
bottom = (bottom + delta.y).max(top + min[1]);
}
[left, top, right - left, bottom - top]
}
fn resize_cursor(direction: ResizeDirection) -> CursorIcon {
use {CursorIcon as C, ResizeDirection as D};
match direction {
+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);
+20 -11
View File
@@ -348,17 +348,26 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T
- **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style:
the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On
Wayland, where a window cannot be placed, the bar hands the move to the compositor. On
Windows and Linux the OS frame is off. On macOS the frame stays for its shadow and rounded
corners, with its title bar and buttons hidden, and is not resizable: AppKit's resize areas
reach well into the content and would take clicks from the ×.
- **Resize.** A movable window resizes from bands 4 pt deep along its edges. Each corner is an
L along both edges, 12 pt long and as deep as the edges. The bands leave out the bar's
buttons, which sit 4 pt in from the window's edge so the corner's grab runs down beside
them; elsewhere the top band lies over the bar's drag area. The OS runs the resize
(`ViewportCommand::BeginResize`), except on macOS, where winit cannot: there the window
follows the pointer by `InnerSize` and `OuterPosition`, keeping the opposite edges and the
160 by 100 pt minimum, and a window released with too little of its bar on a monitor moves
back onto the nearest one, as after a bar drag.
Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and
buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area
paints no shadow of its own around a native window.
- **The ×.** Its place is set in `render/native.rs` (`MACOS_CLOSE`, `OTHER_CLOSE`): the
glyph's inset from the top and trailing edges, its size, and how far the hit area reaches
past the glyph on each side. On macOS the 9 pt glyph sits where macOS puts the close button
of its own windows: centered in the bar, its trailing side 8 pt in. AppKit's corner resize
area covers the glyph's trailing half and takes presses there, so the hit area reaches 10 pt
further toward the leading edge and down to the bar's bottom. Elsewhere the × is an 18 pt
square, its trailing side 7 pt in. With `CRAFT_DOCK_DEBUG_CLOSE=1` in the environment,
native windows outline the ×'s hit area in red and, on macOS, AppKit's resize areas in
orange.
- **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it,
with its own areas and cursors. Its corner area reaches about 15 pt into the window along
each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees
them. Over AppKit's areas the window sets no cursor of its own, which would flicker with
AppKit's. Elsewhere the window resizes from bands 6 pt deep along its edges, and each corner
is an L along both edges, 16 pt long and as deep as the edges. The bands leave out the bar's
buttons, so the corner's grab runs down beside the ×; elsewhere the top band lies over the
bar's drag area. The OS runs the resize from a band (`ViewportCommand::BeginResize`).
- **Close.** The bar's × and the OS close request close the window's panels, as the
floating window's × does. They reopen from the app's Window menu.
- **Place.** `Floating::native` is the desktop rect of the window's content (egui's