mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 17:26:55 +00:00
Merge #244: focus-loss gestures, macOS native resize, tunable ×
This commit is contained in:
commit
10fc07fd2b
12 files changed
+384
-179
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod buttons;
|
||||
pub mod docking;
|
||||
mod focus;
|
||||
pub mod icons;
|
||||
pub mod layout;
|
||||
pub mod panes;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user