diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 573e3812..d9bdf3a6 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"], optional = true } # Fading a native window while it is dragged (`NSWindow.alphaValue`); winit already uses these. [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSResponder", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSEvent", "NSResponder", "NSScreen", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] } [features] diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 41440f30..9e3aa2bc 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -117,6 +117,7 @@ pub struct FloatingFrame<'a> { limits: PanelLimits, resizable: bool, resize_label: &'a str, + opacity: f32, } /// What a [`FloatingFrame`] did this frame. @@ -136,7 +137,7 @@ pub struct FrameResponse { impl<'a> FloatingFrame<'a> { pub fn new(id: Id, rect: Rect) -> Self { - Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window" } + Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window", opacity: 1.0 } } /// The frame stays inside `bounds`; the default is the viewport's content rect. @@ -163,6 +164,12 @@ impl<'a> FloatingFrame<'a> { self } + /// Opacity of everything drawn in the frame, contents included (default 1.0). + pub fn opacity(mut self, opacity: f32) -> Self { + self.opacity = opacity; + self + } + pub fn layer(id: Id) -> LayerId { LayerId::new(Order::Middle, id) } @@ -180,6 +187,7 @@ impl<'a> FloatingFrame<'a> { .movable(false) .constrain_to(bounds); let shown = area.show(ctx, |ui| { + ui.multiply_opacity(self.opacity); // Drawn at `rect` itself, also in egui's first (sizing) pass of a new area. ui.set_min_size(rect.size()); ui.allocate_rect(rect, Sense::hover()); diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index c471b44d..a984f696 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -243,6 +243,10 @@ fn hline(y: f32, x: std::ops::RangeInclusive) -> Rect { Rect::from_x_y_ranges(x, (y - 2.0)..=(y + 2.0)) } +fn vline(x: f32, y: std::ops::RangeInclusive) -> Rect { + Rect::from_x_y_ranges((x - 2.0)..=(x + 2.0), y) +} + /// A floating group's drag bar. fn bar(d: &Driver, panel: u32) -> Pos2 { d.rect("drag-bar", panel).center() @@ -467,12 +471,14 @@ fn group_dropped_on_the_window_edge_without_a_dock_makes_a_new_dock() { let layout = with_floating(docked(side, &[&[&[2]]]), &[(5, [300.0, 300.0, 150.0, 150.0])]); let mut d = Driver::new(layout); d.hold(bar(&d, 5), at); + let x = if new == Side::End { 598.0 } else { 2.0 }; + assert!(d.painted(vline(x, 0.0..=600.0)), "{new:?}: a vertical line at the window edge"); let held = d.shapes.clone(); let out = d.step(vec![button(at, false)]); d.settle(); let rect = d.column_of(5); let shapes = std::mem::replace(&mut d.shapes, held); - assert!(d.previewed(rect), "{new:?}: the new dock's column at the window edge, {rect:?}"); + assert!(!d.previewed(rect), "{new:?}: no rect of the new dock's column"); d.shapes = shapes; assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: new }))); assert_eq!(columns(&d.layout, new), vec![vec![vec![5]]]); @@ -962,3 +968,94 @@ fn defaults_give_way_to_the_filler_but_user_heights_stay() { let d = sized_column(900.0, [Some(200.0), Some(340.0), None]); assert_eq!(drawn_heights(&d), [200.0, 340.0, 900.0 - 14.0 - 540.0]); } + +/// Dock columns left and right of protected canvas 0, as in a creative app: group [1, 7] above +/// [2] at the left, [3] above [4] at the right, each column 200 wide, and a floating window +/// holding group [5, 6]. +fn creative() -> testing::DockDriver { + let dock = |side, groups: &[&[u32]]| Node::Dock { side, columns: vec![Column::new(groups.iter().map(|g| group(g)).collect(), 200.0)] }; + let canvas = Node::Tabs { panels: vec![0], active: 0 }; + let inner = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(200.0), + first: Box::new(canvas), + second: Box::new(dock(Side::End, &[&[3], &[4]])), + }; + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(200.0), + first: Box::new(dock(Side::Start, &[&[1, 7], &[2]])), + second: Box::new(inner), + }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![5, 6], 0, [300.0, 200.0, 200.0, 160.0])] }; + testing::DockDriver::new(layout, TestContent::default()).with_style(|s| { + s.side_edge_zones = false; + s.new_dock_zones = true; + s.float_on_empty_drop = true; + s.title_bar_docks = true; + }) +} + +/// The rect of the result preview the last frame painted: a 2 pt accent outline with a light +/// fill. +fn result_preview>(d: &testing::DockDriver) -> Option { + let fill = DockStyle::default().accent.gamma_multiply(0.08); + d.shapes() + .iter() + .find_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.fill == fill && shape.stroke.width == 0.0 => Some(shape.rect), + _ => None, + }) + .filter(|rect| d.outlined(*rect)) +} + +#[test] +fn a_group_dropped_on_the_window_edge_makes_a_column_there_and_previews_the_edge_line() { + // Group [1, 7] heads the left column, so the column's window edge is named by panel 1: the + // drop names the column by panel 2 instead and moves the whole group out. + let mut d = creative(); + let at = pos2(3.0, 300.0); + d.hold(d.hit(&Hit::DragBar(1)).unwrap().center(), at); + assert!(d.filled(Rect::from_x_y_ranges(0.0..=4.0, 0.0..=600.0)), "the line along the window edge"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 1, to: Target::At { anchor: 2, placement: Placement::Column { after: false } } }]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![1, 7]], vec![vec![2]]]); + + // A floating window's group at the right window edge. + let mut d = creative(); + let at = pos2(797.0, 300.0); + d.hold(d.hit(&Hit::DragBar(5)).unwrap().center(), at); + assert!(d.filled(Rect::from_x_y_ranges(796.0..=800.0, 0.0..=600.0)), "the line along the window edge"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 5, to: Target::At { anchor: 3, placement: Placement::Column { after: false } } }]); + assert_eq!(columns(&d.layout, Side::End), vec![vec![vec![5, 6]], vec![vec![3], vec![4]]]); + + // The bottom edge band between the docks: the line along the canvas's bottom. + let mut d = creative(); + let at = pos2(400.0, 597.0); + d.hold(d.hit(&Hit::DragBar(5)).unwrap().center(), at); + assert!(d.filled(Rect::from_x_y_ranges(204.0..=596.0, 596.0..=600.0)), "the line along the canvas's bottom edge"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::MoveGroup { panel: 5, to: Target::At { placement: Placement::Edge(Zone::Bottom), .. } }]), + "{:?}", + out.actions + ); +} + +#[test] +fn a_tab_dropped_beside_a_dock_group_previews_the_rect_it_takes() { + // The bottom third of group [2], the left column's last group: a new group below it. + let mut d = creative(); + let at = pos2(100.0, 560.0); + d.hold(d.hit(&Hit::Tab(6)).unwrap().center(), at); + let preview = result_preview(&d); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 6, anchor: 2, placement: Placement::Group { below: true, height: None } }]); + d.settle(); + let rect = Rect::from_x_y_ranges(0.0..=200.0, d.hit(&Hit::Tab(6)).unwrap().top()..=600.0); + assert_eq!(preview, Some(rect.shrink(1.0)), "group 6's rect below group 2"); +} diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 9d7b4e0f..b70ec5c2 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -122,16 +122,17 @@ fn a_drop_on_a_groups_outer_third_docks_beside_it_with_its_rect_and_its_middle_j } #[test] -fn a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { +fn without_new_dock_zones_a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { let canvas = Permissions { accepts_tabs: false, ..Permissions::default() }; - let d = driver(&[(1, canvas)]); + let start = || driver(&[(1, canvas)]).with_style(|style| style.new_dock_zones = false); + let d = start(); let (body, rect) = group(&d, 1); for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { - let out = previews_result(driver(&[(1, canvas)]), tab(&d, 3), at, |d| group(d, 3).1); + let out = previews_result(start(), tab(&d, 3), at, |d| group(d, 3).1); assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); } // Its strip joins nothing. - let mut d = driver(&[(1, canvas)]); + let mut d = start(); let out = d.drag(tab(&d, 3), pos2(rect.center().x, rect.top() + 13.0)); assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { anchor: 1, placement: Placement::Tab { .. }, .. })), "{:?}", out.actions); } @@ -164,17 +165,18 @@ fn a_side_without_room_joins_as_a_tab_or_offers_nothing() { } #[test] -fn the_root_edge_band_is_28_pt_and_previews_the_new_frame_along_the_layouts_edge() { +fn the_root_edge_band_is_28_pt_and_previews_a_line_along_the_layouts_edge() { let d = driver(&[]); let (body, _) = group(&d, 1); let x = body.center().x; - let along_the_bottom = |d: &Driver| { - let (_, rect) = group(d, 3); - assert_eq!((rect.left(), rect.right(), rect.bottom()), (0.0, 800.0, 600.0), "along the layout's whole bottom edge"); - rect - }; - let out = previews_result(driver(&[]), tab(&d, 3), pos2(x, 573.0), along_the_bottom); + let window = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let out = previews_line(driver(&[]), tab(&d, 3), pos2(x, 573.0), line(window, Zone::Bottom)); assert!(matches!(&out[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{out:?}"); + let mut d = driver(&[]); + d.drag(tab(&d, 3), pos2(x, 573.0)); + d.settle(); + let (_, rect) = group(&d, 3); + assert_eq!((rect.left(), rect.right(), rect.bottom()), (0.0, 800.0, 600.0), "along the layout's whole bottom edge"); // Past the band, the group's own bottom third. let mut d = driver(&[]); let out = d.drag(tab(&d, 3), pos2(x, 571.0)); @@ -217,12 +219,8 @@ fn between_side_frames_the_outer_edge_band_covers_only_the_canvas_column() { let (side, _) = group(&d, 1); let at = pos2(canvas.center().x, 590.0); let mut d = canvas_driver(Permissions::PROTECTED, |_| {}); - let under_the_canvas = |d: &Driver| { - let (_, rect) = group(d, 3); - assert_eq!(rect.x_range(), canvas.x_range(), "the canvas column's width"); - rect - }; - let out = previews_result(canvas_driver(Permissions::PROTECTED, |_| {}), tab(&d, 3), at, under_the_canvas); + // The line along the canvas column's bottom. + let out = previews_line(canvas_driver(Permissions::PROTECTED, |_| {}), tab(&d, 3), at, line(canvas, Zone::Bottom)); assert_eq!(out, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Bottom) }]); d.drag(tab(&d, 3), at); // The side frames keep their full height. @@ -248,12 +246,8 @@ fn between_docks_the_outer_edge_band_covers_the_part_between_them() { let (timeline, _) = group(&d, 4); let area = Rect::from_x_y_ranges(canvas.x_range(), (canvas.top() - 26.0)..=timeline.bottom()); let at = pos2(canvas.center().x, 590.0); - let between_the_docks = |d: &Driver| { - let (_, rect) = group(d, 3); - assert_eq!((rect.x_range(), rect.bottom()), (area.x_range(), area.bottom()), "along the bottom between the docks"); - rect - }; - let out = previews_result(driver(), tab(&d, 3), at, between_the_docks); + // The line along the bottom between the docks. + let out = previews_line(driver(), tab(&d, 3), at, line(area, Zone::Bottom)); assert!(matches!(&out[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{out:?}"); let mut d = driver(); d.drag(tab(&d, 3), at); @@ -280,15 +274,12 @@ fn over_a_full_width_row_the_top_edge_band_covers_only_the_canvas_column_above_i let d = driver(); let (canvas, _) = group(&d, 0); let at = pos2(canvas.center().x, 5.0); + // The line along the canvas column's top, not the window's. + let out = previews_line(driver(), tab(&d, 3), at, line(canvas, Zone::Top)); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Top) }]); let mut d = driver(); - d.hold(tab(&d, 3), at); - let preview = result_preview(&d).unwrap_or_else(|| panic!("no result preview")); - assert_eq!(preview.x_range(), canvas.shrink(1.0).x_range(), "the canvas column's width, not the window's"); - let out = d.release(at); - d.settle(); - assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Top) }]); + d.drag(tab(&d, 3), at); assert_eq!(wrapped(&d.layout, 3), Some(tabs(&[0])), "{:?}", d.layout); - assert_eq!(preview, group(&d, 3).1.shrink(1.0)); } #[test] @@ -304,8 +295,8 @@ fn an_outer_edge_drop_wraps_the_area_it_previews_when_the_app_applies_other_perm let at = pos2(canvas.center().x, 5.0); let mut d = driver(); d.hold(tab(&d, 3), at); - let preview = result_preview(&d).unwrap_or_else(|| panic!("no result preview")); - assert_eq!(preview.x_range(), Rect::from_x_y_ranges(0.0..=800.0, 0.0..=1.0).shrink2(egui::vec2(1.0, 0.0)).x_range(), "the window's width"); + assert!(d.filled(line(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), Zone::Top)), "the line along the window's top"); + assert_eq!(result_preview(&d), None); let out = d.release(at); let mut applied = layout.clone(); let apply = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() }; @@ -345,7 +336,7 @@ fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() { } #[test] -fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { +fn without_new_dock_zones_a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { let beside = Permissions { accepts_splits: true, ..Permissions::PROTECTED }; let d = canvas_driver(beside, |_| {}); let (body, _) = group(&d, 0); @@ -358,12 +349,20 @@ fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { (Sides::NONE, [None, None]), ] { for (at, zone) in [low, high].into_iter().zip(zones) { - let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); + let split_frames = move |style: &mut DockStyle| { + style.new_dock_zones = false; + style.drop_edges = edges; + }; + let mut d = canvas_driver(beside, split_frames); let out = d.drag(tab(&d, 3), at); match zone { Some(zone) => assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Split(zone) }], "{edges:?}"), None => assert!(!moves(&out), "{edges:?}: {:?}", out.actions), } + // With new dock zones the canvas body takes no drop on any side. + let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); + let out = d.drag(tab(&d, 3), at); + assert!(!moves(&out), "{edges:?}: {:?}", out.actions); } } } @@ -519,6 +518,17 @@ fn result_preview(d: &Driver) -> Option { .filter(|rect| d.outlined(*rect)) } +/// Drags `from` to `at` and releases: the preview while held is the drop line `line`, with no +/// result rect. Returns the drop's actions. +fn previews_line(mut d: Driver, from: Pos2, at: Pos2, line: Rect) -> Vec> { + d.hold(from, at); + assert!(d.filled(line), "the line {line:?}"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + d.settle(); + out.actions +} + /// Drags `from` to `at` and back to rest: the preview while held is the rect `rect_of` reads /// once the drop is applied, inset by its 1 pt margin. Returns the drop's actions. fn previews_result(mut d: Driver, from: Pos2, at: Pos2, rect_of: impl Fn(&Driver) -> Rect) -> Vec> { @@ -570,7 +580,8 @@ fn a_dock_drop_previews_the_rect_the_group_or_column_takes() { // The column's inner edge: a new column after it, the full height. let out = previews_result(dock_driver(), tab(&d, 4), pos2(upper.right() + 6.0, 200.0), |d| column_of(d, 4)); assert_eq!(out, vec![Action::Move { panel: 4, anchor: 2, placement: Placement::Column { after: true } }]); - // The window's right edge: a new dock. - let out = previews_result(dock_driver(), tab(&d, 4), pos2(795.0, 200.0), |d| column_of(d, 4)); + // The window's right edge: a new dock, previewed by the line along that edge. + let window = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let out = previews_line(dock_driver(), tab(&d, 4), pos2(795.0, 200.0), line(window, Zone::Right)); assert_eq!(out, vec![Action::MovePanel { panel: 4, to: Target::NewDock { side: Side::End } }]); } diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index e853bc20..34d1a4b2 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -51,6 +51,8 @@ struct Content { /// Canvas 0 accepts side splits, as an app that opens panels beside it does. beside: bool, drawn: Vec, + /// A preview the host draws for every drag, from `resolve_drag`. + host_preview: Option, } impl DockContent for Content { @@ -71,6 +73,9 @@ impl DockContent for Content { fn allow_native(&self, _: &u32) -> bool { self.native } + fn resolve_drag(&mut self, _: &Context, _: &DockStyle, _: &DropRequest<'_, u32>) -> Option { + self.host_preview.map(|rect| DropFeedback { preview: Some(rect), insertion: None }) + } } #[derive(Clone, Copy, PartialEq)] @@ -106,6 +111,8 @@ struct Driver { hide: Hide, modifiers: Modifiers, time: f64, + /// What the last frame painted. + shapes: Vec, } impl Driver { @@ -120,6 +127,7 @@ impl Driver { hide: Hide::None, modifiers: Modifiers::NONE, time: 0.0, + shapes: Vec::new(), }; driver.step(vec![]); driver.step(vec![]); @@ -157,6 +165,7 @@ impl Driver { }); let mut full = full; full.textures_delta.clear(); + self.shapes = full.shapes; output } @@ -199,6 +208,22 @@ impl Driver { self.step(vec![button(at, false)]) } + /// Opacity of the floating window that the last frame drew at `rect`: the alpha of its + /// opaque background fill. + fn window_opacity(&self, rect: [f32; 4]) -> Option { + let rect = Rect::from_min_size(pos2(rect[0], rect[1]), vec2(rect[2], rect[3])); + fn find(shape: &egui::Shape, rect: Rect) -> Option { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, rect)), + egui::Shape::Rect(r) if r.rect.min.distance(rect.min) < 0.5 && r.rect.max.distance(rect.max) < 0.5 && r.fill.a() > 0 => { + Some(f32::from(r.fill.a()) / 255.0) + } + _ => None, + } + } + self.shapes.iter().find_map(|s| find(&s.shape, rect)) + } + fn wait(&mut self) { for _ in 0..45 { self.step(vec![]); @@ -764,3 +789,59 @@ fn undock_panel_group_floats_one_group_of_a_floating_window() { menu(&mut h, 5); assert_eq!(h.query_all_by_label("Undock Panel Group").count(), 0); } + +#[test] +fn a_title_bar_drag_fades_over_a_preview_the_host_draws() { + let mut d = Driver::new(workspace(), false); + d.content.host_preview = Some(Rect::from_min_size(pos2(20.0, 20.0), vec2(100.0, 100.0))); + let title = d.title(8).center(); + d.step(vec![Event::PointerMoved(title), button(title, true)]); + d.step(vec![Event::PointerMoved(title + vec2(8.0, 6.0))]); + d.step(vec![Event::PointerMoved(pos2(650.0, 150.0))]); + let rect = window_of(&d.layout, 8).unwrap().rect; + d.step(vec![]); + let shown = d.window_opacity(rect).unwrap(); + assert!((shown - 0.75).abs() < 0.01, "{shown}"); +} + +#[test] +fn an_in_window_floating_window_fades_only_while_its_title_bar_drag_highlights_a_drop_target() { + let mut d = Driver::new(workspace(), false); + let strip = d.tab(4).unwrap().center(); + let empty = pos2(650.0, 150.0); + // Opacity of window W2 (dragged) and W1 once the pointer rests at `at`: the window draws + // before the preview, so it fades a frame after the target lights up. + let over = |d: &mut Driver, at: egui::Pos2| { + d.step(vec![Event::PointerMoved(at)]); + let (dragged, other) = (window_of(&d.layout, 8).unwrap().rect, window_of(&d.layout, 5).unwrap().rect); + d.step(vec![]); + (d.window_opacity(dragged).unwrap(), d.window_opacity(other).unwrap()) + }; + let shown = |d: &Driver| d.window_opacity(window_of(&d.layout, 8).unwrap().rect).unwrap(); + let grab = |d: &mut Driver| { + let title = d.title(8).center(); + d.step(vec![Event::PointerMoved(title), button(title, true)]); + d.step(vec![Event::PointerMoved(title + vec2(8.0, 6.0))]); + }; + assert_eq!(shown(&d), 1.0); + grab(&mut d); + assert_eq!(over(&mut d, empty), (1.0, 1.0), "over empty space"); + let (dragged, other) = over(&mut d, strip); + assert!((dragged - 0.75).abs() < 0.01 && other == 1.0, "over a drop target: {dragged}, {other}"); + assert_eq!(over(&mut d, empty).0, 1.0, "after leaving the target"); + assert!((over(&mut d, strip).0 - 0.75).abs() < 0.01); + // Released off the target the window stays floating, opaque. + d.step(vec![Event::PointerMoved(empty), button(empty, false)]); + d.step(vec![]); + assert_eq!(shown(&d), 1.0, "after the release"); + assert!(window_of(&d.layout, 8).is_some()); + + // Escape over a target puts the window back, opaque. + grab(&mut d); + assert!((over(&mut d, strip).0 - 0.75).abs() < 0.01); + d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + d.step(vec![]); + assert_eq!(shown(&d), 1.0, "after Escape"); + d.step(vec![button(strip, false)]); + assert!(window_of(&d.layout, 8).is_some()); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 9b0273d9..1f9f5d64 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -32,7 +32,7 @@ pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PA pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, - WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, + WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, send_keys, typing_window, }; pub use targets::{ DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, Sides, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker, diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index d141bd27..40122a0c 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1,10 +1,10 @@ //! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's //! stand-in for OS windows. -use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; +use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; use super::render::{ - APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, wayland, + APPKIT_CORNER, DragPointer, INACTIVE_PALETTE, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, resize_bands, wayland, }; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, WindowKind, Workspace}; @@ -17,6 +17,8 @@ const ORIGIN: Pos2 = pos2(100.0, 50.0); struct Content { /// Panels that may not close. fixed: Vec, + /// The protected canvas that takes splits beside it, as PhotoCraft's does. + canvas: Option, /// Panels that stay in-window. in_window: Vec, /// Viewer panels: their windows are normal windows. @@ -25,6 +27,8 @@ struct Content { text: Option, typed: String, focus_text: bool, + /// The text field gives up keyboard focus on the next frame. + blur_text: bool, /// The panel with a button that takes focus. focused_button: Option, /// The panel with a counter button, its clicks, and where it was drawn. @@ -62,6 +66,10 @@ struct Content { drops: Vec<(u32, Pos2)>, /// Drop sites whose release found egui's drag-and-drop payload. payload_drops: Vec, + /// Key and clipboard events panel 1 saw in the main window, and the text field's panel in + /// its own window. + main_keys: Vec, + field_keys: Vec, } /// How a test's app drag source marks its drag. @@ -90,6 +98,9 @@ impl DockContent for Content { if std::mem::take(&mut self.focus_text) { field.request_focus(); } + if std::mem::take(&mut self.blur_text) { + field.surrender_focus(); + } } if self.focused_button == Some(*panel) { ui.button("OK").request_focus(); @@ -135,6 +146,13 @@ impl DockContent for Content { } } self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty()); + let main = ui.ctx().viewport_id() == egui::ViewportId::ROOT; + if (main && *panel == 1) || (!main && self.text == Some(*panel)) { + let keys = ui.input(|i| { + i.events.iter().filter(|e| matches!(e, Event::Key { .. } | Event::Copy | Event::Cut | Event::Paste(_))).cloned().collect::>() + }); + (if main { &mut self.main_keys } else { &mut self.field_keys }).extend(keys); + } if ui.rect_contains_pointer(ui.max_rect()) { let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b))); self.pressed.extend(pressed.map(|b| (*panel, b))); @@ -146,6 +164,9 @@ impl DockContent for Content { } fn permissions(&self, panel: &u32) -> Permissions { + if self.canvas == Some(*panel) { + return Permissions { accepts_splits: true, ..Permissions::PROTECTED }; + } Permissions { closable: !self.fixed.contains(panel), ..Permissions::default() } } @@ -353,6 +374,62 @@ fn a_restored_window_opens_on_a_connected_monitor() { assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); } +/// The last `InnerSize`, `MinInnerSize` and `MaxInnerSize` sent to `viewport`. +fn size_commands(d: &DockDriver, viewport: egui::ViewportId) -> [Option; 3] { + let mut sizes = [None; 3]; + for (_, c) in d.host().commands.iter().filter(|(v, _)| *v == viewport) { + match c { + ViewportCommand::InnerSize(s) => sizes[0] = Some(*s), + ViewportCommand::MinInnerSize(s) => sizes[1] = Some(*s), + ViewportCommand::MaxInnerSize(s) => sizes[2] = Some(*s), + _ => {} + } + } + sizes +} + +#[test] +fn a_minimized_native_window_shrinks_to_its_bar_and_keeps_its_expanded_size() { + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + let open = os_window(&d, 5).unwrap().0; + let (native, rect) = (window(&d, 5).native, window(&d, 5).rect); + assert_eq!(native, Some([open.left(), open.top(), 240.0, 300.0])); + + // Its bar and its group's tab strip, at the same width and top-left corner; only the width + // resizes. + let collapsed = BAR + super::DockStyle::default().tab_height; + d.layout.apply(Action::SetMinimized { panel: 5, minimized: true }).unwrap(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0, Rect::from_min_size(open.min, vec2(240.0, collapsed))); + let [_, min, max] = size_commands(&d, viewport); + assert_eq!((min.map(|s| s.y), max.map(|s| s.y)), (Some(collapsed), Some(collapsed))); + + // The layout keeps the expanded size after the window reports the collapsed one, past the + // wait for commanded moves, and takes a new width. + for _ in 0..40 { + d.step(vec![]); + } + assert_eq!((window(&d, 5).native, window(&d, 5).rect), (native, rect)); + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(open.min, vec2(300.0, collapsed)); + for _ in 0..40 { + d.step(vec![]); + } + assert_eq!(window(&d, 5).native, Some([open.left(), open.top(), 300.0, 300.0])); + assert_eq!(window(&d, 5).rect[2..], [300.0, 300.0]); + + // Expanded: the saved height comes back, and the height resizes again. + d.layout.apply(Action::SetMinimized { panel: 5, minimized: false }).unwrap(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0, Rect::from_min_size(open.min, vec2(300.0, 300.0))); + assert_eq!(size_commands(&d, viewport), [Some(vec2(300.0, 300.0)), Some(vec2(160.0, 100.0)), Some(egui::Vec2::INFINITY)]); + // The limits change before the size: the OS clamps a new size to the limits it has. + let commands: Vec<_> = d.host().commands.iter().filter(|(v, _)| *v == viewport).map(|(_, c)| c.clone()).collect(); + let last = |f: fn(&ViewportCommand) -> bool| commands.iter().rposition(f); + assert!(last(|c| matches!(c, ViewportCommand::MaxInnerSize(_))) < last(|c| matches!(c, ViewportCommand::InnerSize(_))), "{commands:?}"); + assert_eq!(window(&d, 5).native, Some([open.left(), open.top(), 300.0, 300.0])); +} + #[test] fn a_locked_area_moves_no_native_window_and_records_where_the_os_put_it() { let mut d = driver(layout(true)); @@ -421,37 +498,40 @@ fn id_of(d: &DockDriver) -> u64 { } #[test] -fn floating_windows_hide_while_the_app_is_inactive() { - assert!(floats_shown(true, 10.0, 10.0)); - assert!(floats_shown(false, 10.2, 10.0)); - assert!(!floats_shown(false, 10.3, 10.0)); - +fn palettes_stay_shown_at_the_normal_level_while_the_app_is_inactive() { + use egui::{ViewportId, WindowLevel}; let mut d = driver(layout(true)); - let viewport = d.viewport(window(&d, 5).id); - let hides = - |d: &DockDriver| d.host().commands.iter().filter(|(v, c)| *v == viewport && *c == ViewportCommand::Visible(false)).count(); - // Focus moving to the floating window keeps it. - d.host().focus = None; - d.step(vec![]); - d.host().focus = Some(viewport); - for _ in 0..30 { - d.step(vec![]); - } - assert_eq!(hides(&d), 0); - // Another application: hidden after the delay, shown again on return. - d.host().focus = None; - for _ in 0..10 { - d.step(vec![]); - } - assert_eq!(hides(&d), 0, "not yet"); - for _ in 0..10 { - d.step(vec![]); - } - assert_eq!(hides(&d), 1); - assert!(!d.host().windows[&viewport].visible); - d.host().focus = Some(egui::ViewportId::ROOT); - d.step(vec![]); - assert!(d.host().windows[&viewport].visible); + let palette = d.viewport(window(&d, 5).id); + let focus = |d: &mut DockDriver, window: Option, frames: usize| { + d.host().focus = window; + for _ in 0..frames { + d.step(vec![]); + } + }; + let level = |d: &DockDriver| d.host().windows[&palette].level; + let told = |d: &DockDriver| d.host().commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Focus moving to the palette, with a frame of none between, keeps it on top. + focus(&mut d, None, 1); + focus(&mut d, Some(palette), 30); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Another application: the palette drops behind it after the delay, and shows on, so + // files dragged from that application can reach it. + focus(&mut d, None, 10); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop), "not yet"); + focus(&mut d, None, 10); + assert_eq!(level(&d), Some(INACTIVE_PALETTE)); + // Back: on top again. + focus(&mut d, Some(ViewportId::ROOT), 1); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Blocked by a modal, it stays at the normal level, away and back. + d.input_blocked = true; + focus(&mut d, None, 20); + assert_eq!(level(&d), Some(WindowLevel::Normal)); + focus(&mut d, Some(ViewportId::ROOT), 5); + assert_eq!(level(&d), Some(WindowLevel::Normal)); + assert!(d.host().windows[&palette].visible); + assert!(!told(&d), "{:?}", d.host().commands); } #[test] @@ -471,18 +551,20 @@ fn viewer_windows_stay_shown_at_normal_level() { assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); assert_eq!(level(&d, mixed), Some(egui::WindowLevel::Normal)); - // Another application: only the palette hides, and the others get no visibility command. + // Another application: the palette drops behind it; the viewers stay at the normal level. d.host().focus = None; for _ in 0..20 { d.step(vec![]); } - let shown = |d: &DockDriver| [viewer, palette, mixed].map(|v| d.host().windows[&v].visible); - assert_eq!(shown(&d), [true, false, true]); - let told = |d: &DockDriver, v| d.host().commands.iter().any(|(w, c)| *w == v && matches!(c, ViewportCommand::Visible(_))); - assert!(!told(&d, viewer) && !told(&d, mixed)); + assert_eq!( + [viewer, palette, mixed].map(|v| level(&d, v)), + [Some(egui::WindowLevel::Normal), Some(INACTIVE_PALETTE), Some(egui::WindowLevel::Normal)] + ); d.host().focus = Some(egui::ViewportId::ROOT); d.step(vec![]); - assert_eq!(shown(&d), [true, true, true]); + assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); + assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); + assert!(!d.host().commands.iter().any(|(_, c)| matches!(c, ViewportCommand::Visible(_))), "no window hides"); } #[test] @@ -532,7 +614,7 @@ fn the_app_comes_forward_with_its_viewers_when_it_is_activated() { focus(&mut d, Some(MAIN), 2); assert_eq!(levels(&mut d), []); // Another application, then back through the main window: the viewers are raised back to - // front and the main window last, without focus; the palette only shows again. + // front and the main window last, without focus; the palette only goes back on top. focus(&mut d, None, 20); focus(&mut d, Some(MAIN), 10); assert_eq!(levels(&mut d), raised(&[back, front, MAIN])); @@ -951,6 +1033,55 @@ fn a_native_windows_bar_docks_it_at_frame_edges_and_dock_lines_but_not_a_body_ce assert!(in_root(&d, 5) && in_root(&d, 6)); } +#[test] +fn over_a_canvas_body_a_window_stays_floating_and_a_tab_floats_while_the_edge_bands_dock() { + // PhotoCraft: a dock column of [2] above [3, 4] beside canvas 0, which takes splits, and + // window [5, 6] floating. + let accent = Color32::from_rgb(250, 10, 200); + let dock = Node::Dock { + side: super::Side::Start, + columns: vec![super::Column::new(vec![super::Group::new(vec![2], 0), super::Group::new(vec![3, 4], 0)], 240.0)], + }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[0])) }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![5, 6], 0, [300.0, 80.0, 240.0, 200.0])] }; + let start = || { + DockDriver::new(layout.clone(), Content { canvas: Some(0), ..Content::default() }) + .with_style(move |s| { + s.accent = accent; + s.float_hint = false; + }) + .with_native_host(ORIGIN) + }; + // The canvas spans x = 244 to 800 and the whole height: no tab strip. + for at in [pos2(520.0, 150.0), pos2(520.0, 450.0)] { + let mut d = start(); + let before = accented(d.shapes(), accent); + let id = window(&d, 5).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let local = d.window_local(id, ORIGIN + at.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + d.step(vec![]); + assert_eq!(accented(d.shapes(), accent), before, "{at:?}: no preview"); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{at:?}: {:?}", out.actions); + assert!(window(&d, 5).contains(&6), "{at:?}"); + } + // A docked tab dropped there floats. + let mut d = start(); + let tab = d.hit(&Hit::Tab(3)).unwrap().center(); + let out = d.drag(tab, pos2(520.0, 450.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); + // The window's right edge, where no dock is yet, and the bottom edge band beside the canvas + // still dock the window. + let mut d = start(); + let out = drag_bar_to(&mut d, 5, pos2(795.0, 300.0)); + assert_eq!(window_move(&out), Some(&Target::NewDock { side: super::Side::End }), "{:?}", out.actions); + let mut d = start(); + let out = drag_bar_to(&mut d, 5, pos2(520.0, 590.0)); + assert!(matches!(window_move(&out), Some(Target::At { placement: Placement::Edge(super::Zone::Bottom), .. })), "{:?}", out.actions); +} + #[test] fn escape_during_a_bar_drag_puts_the_window_back() { let mut d = driver(layout(true)); @@ -1110,6 +1241,45 @@ fn a_bar_drag_follows_the_pointer_when_moves_land_late() { assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([to.x, to.y])); } +/// A bar drag at a steady pointer speed under AppKit's timing: the window's reported place is +/// fresh, while pointer events are measured against where the window was a frame back on some +/// frames. Returns the window's step each frame. Released, the layout keeps where it ended. +fn bar_drag_with_stale_pointer(tells_cursor: bool) -> Vec { + let mut d = driver(layout(true)); + d.host().tells_cursor = tells_cursor; + d.host().stale = vec![1, 0, 1, 1, 0]; + let id = id_of(&d); + let viewport = d.viewport(id); + let grab = pos2(40.0, 9.0); + let mut pointer = d.host().windows[&viewport].rect.min + grab.to_vec2(); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + let mut place = d.host().windows[&viewport].rect.min; + let mut steps = Vec::new(); + // On to the desktop right of the main window, where the release docks nothing. + for _ in 0..24 { + pointer += vec2(20.0, 5.0); + d.window_pointer(id, pointer); + let now = d.host().windows[&viewport].rect.min; + steps.push(now - place); + place = now; + } + let local = pointer - place.to_vec2(); + d.window_step(id, vec![button(local, false)]); + d.settle(); + assert_eq!(window(&d, 5).native.map(|r| pos2(r[0], r[1])), Some(d.host().windows[&viewport].rect.min)); + steps +} + +#[test] +fn a_bar_drag_follows_the_cursor_where_pointer_events_trail_the_window() { + // Without the cursor the window overshoots and steps back. + let steps = bar_drag_with_stale_pointer(false); + assert!(steps.iter().any(|s| s.x < 0.0), "{steps:?}"); + // With it, every frame moves the window by the pointer's step. + let steps = bar_drag_with_stale_pointer(true); + assert!(steps.iter().all(|s| *s == vec2(20.0, 5.0)), "{steps:?}"); +} + #[test] fn a_native_windows_content_is_its_origin_where_its_frame_differs() { // A title bar's height between the window's outer position and its content. @@ -1720,11 +1890,13 @@ fn a_blocked_palette_stays_shown_and_behind_the_main_window_when_the_app_returns d.step(vec![]); } }; - // Showing it again would raise it over the modal and, on macOS, give it focus. + // Away and back, it stays shown at the normal level: showing it or putting it on top could + // raise it over the modal, and showing it would, on macOS, give it focus. focus(&mut d, None); assert!(d.host().windows[&palette].visible); focus(&mut d, Some(palette)); let host = d.host(); + assert_eq!(host.windows[&palette].level, Some(WindowLevel::Normal)); assert!(!host.commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))), "{:?}", host.commands); // Activated through the palette, the main window comes in front of it, with no viewers. let levels: Vec<_> = host.commands.iter().filter(|(_, c)| matches!(c, ViewportCommand::WindowLevel(_))).cloned().collect(); @@ -1736,6 +1908,61 @@ fn a_blocked_palette_stays_shown_and_behind_the_main_window_when_the_app_returns assert_eq!(levels, expected); } +/// A menu's key equivalent, as an app's menu glue reports it in the main window's input. +fn menu_key(key: Key) -> Vec { + let press = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }; + vec![press(true), press(false)] +} + +#[test] +fn menu_keys_go_to_the_focused_text_field_of_the_native_window_with_the_keyboard() { + let has = |events: &[Event], key: Key| events.iter().any(|e| matches!(e, Event::Key { key: k, .. } if *k == key)); + let pasted = |d: &DockDriver, viewport| d.host().commands.contains(&(viewport, ViewportCommand::RequestPaste)); + let mut d = driver(layout(true)); + let viewport = d.viewport(id_of(&d)); + (d.content.text, d.content.focus_text) = (Some(5), true); + d.host().focus = Some(viewport); + d.settle(); + assert_eq!(super::typing_window(&d.ctx), Some(viewport)); + // Between frames, where an app's `raw_input_hook` asks, the answer is the last frame's. + d.content.blur_text = true; + d.step(vec![]); + assert_eq!(super::typing_window(&d.ctx), None, "right after the field loses focus"); + d.content.focus_text = true; + d.step(vec![]); + assert_eq!(super::typing_window(&d.ctx), Some(viewport), "right after the field takes focus"); + d.step(menu_key(Key::V)); + assert!(!has(&d.content.main_keys, Key::V), "{:?}", d.content.main_keys); + assert!(pasted(&d, viewport), "the field's window asks for the clipboard text"); + d.step(menu_key(Key::C)); + assert!(!has(&d.content.main_keys, Key::C)); + assert!(d.content.field_keys.contains(&Event::Copy), "{:?}", d.content.field_keys); + // Saving is the app's, from any window. + d.step(menu_key(Key::S)); + assert!(has(&d.content.main_keys, Key::S)); + assert!(!has(&d.content.field_keys, Key::S)); + + // Under a block the window's field is inert: the main window keeps every key. + d.input_blocked = true; + d.settle(); + assert_eq!(super::typing_window(&d.ctx), None); + d.host().commands.clear(); + d.step(menu_key(Key::V)); + assert!(has(&d.content.main_keys, Key::V)); + assert!(!pasted(&d, viewport)); + + // With no focused field, nothing moves. + let mut d = driver(layout(true)); + let viewport = d.viewport(id_of(&d)); + d.content.text = Some(5); + d.host().focus = Some(viewport); + d.settle(); + d.step(menu_key(Key::V)); + assert!(has(&d.content.main_keys, Key::V)); + assert!(d.content.field_keys.is_empty(), "{:?}", d.content.field_keys); + assert!(!pasted(&d, viewport)); +} + #[test] fn a_focused_field_and_a_held_press_stay_inert_across_a_block() { let mut d = blocked_driver(); @@ -2408,15 +2635,41 @@ fn repeated_discards_deliver_the_release_and_drop_once() { } #[test] -fn a_native_window_fades_only_while_its_drag_can_dock() { - let drag = |docks| WindowDrag { window: 1, grab: vec2(10.0, 10.0), to: pos2(100.0, 100.0), start: pos2(100.0, 100.0), docks }; - assert_eq!(drag_opacity(None, false, true), 1.0); - // A bar drag that only moves the window stays opaque. - assert_eq!(drag_opacity(Some(&drag(false)), false, false), 1.0); - assert_eq!(drag_opacity(Some(&drag(false)), false, true), 0.75); - // A drag by the window's only tab or group docks either way. - assert_eq!(drag_opacity(Some(&drag(true)), false, false), 0.75); - // With Cmd/Ctrl held the drag only moves the window. - assert_eq!(drag_opacity(Some(&drag(false)), true, true), 1.0); - assert_eq!(drag_opacity(Some(&drag(true)), true, false), 1.0); +fn a_native_window_fades_only_while_its_bar_drag_highlights_a_drop_target() { + let bar_to = |d: &mut DockDriver, to: Pos2| { + let id = window(d, 5).id; + let local = d.window_local(id, ORIGIN + to.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + drag_opacity(&d.ctx, d.id) + }; + let grab = |d: &mut DockDriver| { + let id = window(d, 5).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + id + }; + // A bar that only moves its window highlights nothing. + let mut d = driver(layout(true)).with_style(|s| { + s.command_press_floats = true; + s.title_bar_docks = false; + }); + grab(&mut d); + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 1.0, "a move that cannot dock"); + + let mut d = driver(layout(true)); + let id = grab(&mut d); + // Past the main window's right side: the desktop. + assert_eq!(bar_to(&mut d, pos2(1000.0, 300.0)), 1.0, "over empty space"); + // The right edge zone of frame [3, 4]. + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 0.75, "over a drop target"); + // The middle of the frame is no target for a whole window. + assert_eq!(bar_to(&mut d, pos2(600.0, 300.0)), 1.0, "after leaving the target"); + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 0.75); + // With Cmd/Ctrl held the drop floats and nothing is highlighted. + d.modifiers = Modifiers::COMMAND; + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 1.0, "a floating drop"); + d.modifiers = Modifiers::NONE; + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 0.75); + d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(drag_opacity(&d.ctx, d.id), 1.0, "after the drop"); } diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 5376788e..a868744f 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -25,6 +25,9 @@ pub(super) const POINTER: &str = "drag-pointer"; /// Key of an Escape that reached one of the application's windows other than the drag's /// owner. Keys go to the window with keyboard focus, which need not be the one dragging. const ESCAPE: &str = "drag-escape"; +/// Key of whether a native window's bar drag highlighted a drop target on the last frame, under +/// the area's id: [`Cross::highlighted`] as [`Renderer::end_cross`] left it. +pub(super) const HIGHLIGHTED: &str = "drag-highlighted"; /// The pointer of a drag, as the window that owns the drag last saw it. #[derive(Clone, Copy, Debug, PartialEq)] @@ -99,6 +102,9 @@ pub(in crate::docking) struct Cross

{ pub moved: Option, /// Resolving a window move that docks whatever `title_bar_docks` says. pub docks: bool, + /// A native window's bar drag highlighted a drop target this frame, in whichever window + /// drew its preview. + pub highlighted: bool, /// A native window's dragged widget is one of the area's controls: egui keeps a drag per /// viewport. pub held: bool, @@ -122,6 +128,7 @@ impl

Default for Cross

{ bar: false, moved: None, docks: false, + highlighted: false, held: false, pointed: None, viewports: Vec::new(), @@ -315,7 +322,7 @@ impl Renderer<'_, P> { let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return }; if let Some(Dest::Main(local)) = self.destination(&pointer, None) { (self.cross.bar, self.cross.docks) = (true, pointer.docks); - self.paint_group_drag(ui, &drag, local, pointer.command); + self.cross.highlighted |= self.paint_group_drag(ui, &drag, local, pointer.command); (self.cross.bar, self.cross.docks) = (false, false); } } @@ -371,7 +378,7 @@ impl Renderer<'_, P> { if let Some(window) = pointer.window { if let Some(drag) = self.window_group_drag(window) { self.cross.docks = pointer.docks; - self.paint_group_drag(ui, &drag, local, pointer.command); + self.cross.highlighted |= self.paint_group_drag(ui, &drag, local, pointer.command); self.cross.docks = false; } return; @@ -391,9 +398,12 @@ impl Renderer<'_, P> { } } - /// End of the main window's frame: forget the pointer of a finished drag. + /// End of the main window's frame: forget the pointer of a finished drag, and keep whether + /// a bar drag highlighted a drop target. The dragged window sets its opacity from that on + /// the next frame: it draws before the main window paints its preview. pub(in crate::docking) fn end_cross(&mut self, ctx: &Context) { let id = self.id; + ctx.data_mut(|d| d.insert_temp(id.with(HIGHLIGHTED), self.cross.highlighted)); let active = ctx.data(|d| { d.get_temp::>(id.with("drag")).is_some() || d.get_temp::>(id.with("group-drag")).is_some() diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 8ae8ce84..4eaab778 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -287,7 +287,7 @@ impl Renderer<'_, P> { outer.min(outer + inward * self.style.new_dock_band)..=outer.max(outer + inward * self.style.new_dock_band), rect.y_range(), )); - self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); + self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line, outer: true }); return; } if usable(rect) { @@ -319,9 +319,11 @@ impl Renderer<'_, P> { group.sides[1] = Some(right.clone()); } if index == 0 { - self.dock_targets.push(DockDrop { target: before.0, band: outer_band, line: before.1 }); + // Inside the window, as the new-dock band's line is. + let line = vertical_line(x + inward * line_width * 0.5, rect.y_range(), line_width); + self.dock_targets.push(DockDrop { target: before.0, band: outer_band, line, outer: true }); } - self.dock_targets.push(DockDrop { target: after.0, band: inner_band, line: after.1 }); + self.dock_targets.push(DockDrop { target: after.0, band: inner_band, line: after.1, outer: false }); drawn.push((index, column, first, width, inner)); } // Registered after the columns, so the narrow hit areas win at the edges. @@ -506,7 +508,7 @@ impl Renderer<'_, P> { let push = |targets: &mut Vec>, target: DockTarget

, band: Rect, line: Rect| { let band = band.intersect(view); if usable(band) { - targets.push(DockDrop { target, band, line }); + targets.push(DockDrop { target, band, line, outer: false }); } }; let mut previous: Option<&P> = None; @@ -680,7 +682,7 @@ impl Renderer<'_, P> { let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys); let width = self.style.drop_line_width; let line = vertical_line(x + inward.signum() * width * 0.5, ys, width); - candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); + candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line, outer: true }); } candidates } @@ -724,25 +726,36 @@ impl Renderer<'_, P> { body_zone(group.body, 0.0, self.style.side_zone_fraction, group.center, pointer)? }; let (target, line) = group.side(zone)?.clone(); - Some(DockDrop { target, band: group.body, line }) + Some(DockDrop { target, band: group.body, line, outer: false }) } - /// The dock target a whole group or column drag would take at `pointer`, with the action - /// and its line. A target that leaves the layout unchanged is a no-op: `None` action. - pub(super) fn dock_group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { + /// The dock target a whole group or column drag would take at `pointer`, with the action. + /// A target that leaves the layout unchanged is a no-op: `None` action. A target named by + /// one of the moving panels, such as the window edge of the moving group's own column, is + /// named by another panel of that column or group, as for a tab drag. + pub(super) fn dock_group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, DockDrop

)> { let through = self.looks_through(drag); if self.dock_covered(pointer, through.then_some(&drag.panel)) { return None; } let drop = self.dock_drop(pointer, if through { &drag.members } else { &[] })?; - let action = group_action(drag, drop.target.to()); - self.checked(action).map(|action| (action, drop.line)) + let to = match drop.target.to() { + Target::At { anchor, placement } if drag.members.contains(&anchor) => { + let unit = if matches!(placement, Placement::Column { .. }) { Unit::Column } else { Unit::Group }; + match self.layout.members(&anchor, unit).ok()?.into_iter().find(|p| !drag.members.contains(p)) { + Some(anchor) => Target::At { anchor, placement }, + None => return Some((None, drop)), + } + } + to => to, + }; + self.checked(group_action(drag, to)).map(|action| (action, drop)) } /// The dock target a single tab drag would take: a new group, column or dock of that panel. /// A panel alone in its group takes the group's state to a new dock; one that shares its /// group leaves the other tabs behind (P6). - pub(super) fn dock_tab_proposal(&self, panel: &P, pointer: Pos2) -> Option<(Option>, Rect)> { + pub(super) fn dock_tab_proposal(&self, panel: &P, pointer: Pos2) -> Option<(Option>, DockDrop

)> { if self.dock_covered(pointer, None) { return None; } @@ -754,7 +767,7 @@ impl Renderer<'_, P> { let unit = if matches!(placement, Placement::Column { .. }) { Unit::Column } else { Unit::Group }; match self.layout.members(panel, unit).ok()?.into_iter().find(|p| p != panel) { Some(anchor) => Action::Move { panel: panel.clone(), anchor, placement }, - None => return Some((None, drop.line)), + None => return Some((None, drop)), } } Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, @@ -764,7 +777,7 @@ impl Renderer<'_, P> { to @ Target::NewDock { .. } => Action::MovePanel { panel: panel.clone(), to }, Target::Float { .. } => return None, }; - self.checked(action).map(|action| (action, drop.line)) + self.checked(action).map(|action| (action, drop)) } /// `Some(None)` when the action is valid but changes nothing apart from sizes @@ -775,8 +788,8 @@ impl Renderer<'_, P> { Some((!candidate.same_arrangement(self.layout)).then_some(action)) } - /// The line of a dock drop that shows no result rect: into an iconic column, or any dock - /// drop with [`DockStyle::side_edge_zones`]. + /// The line of a drop that shows no result rect: on the window edge, into an iconic column, + /// or any dock drop with [`DockStyle::side_edge_zones`]. pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect) { if usable(line) { let painter = self.preview_painter(ctx); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index fded5aff..256b128f 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -39,15 +39,19 @@ impl Renderer<'_, P> { pub(super) fn window_limits(&self, window: &Floating

) -> PanelLimits { let chrome = self.window_chrome(window); let tree = node_limits(&window.root, self.style, &*self.content); - let body = match (&window.root, window.minimized) { - (Node::Tabs { .. }, true) => (self.style.tab_height, self.style.tab_height), - (_, true) => (0.0, 0.0), - (_, false) => (tree.min.y, tree.max.y), + let body = match window.minimized { + true => (self.minimized_body(window), self.minimized_body(window)), + false => (tree.min.y, tree.max.y), }; let min = vec2(tree.min.x, chrome + body.0).min(vec2(1_000_000.0, 1_000_000.0)); PanelLimits { min, max: vec2(tree.max.x, chrome + body.1).max(min).min(vec2(1_000_000.0, 1_000_000.0)) } } + /// The height of a minimized window's body: the tab strip of its only group, or nothing. + pub(super) fn minimized_body(&self, window: &Floating

) -> f32 { + if matches!(window.root, Node::Tabs { .. }) { self.style.tab_height } else { 0.0 } + } + /// The title bar and, for a movable (resizable) window, the resize band above it. fn window_chrome(&self, window: &Floating

) -> f32 { let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; @@ -76,11 +80,13 @@ impl Renderer<'_, P> { let movable = window.panels().all(|panel| self.permissions(panel).movable); let style = ChromeStyle::from_dock(self.style); let resize_label = self.style.resize_window_label.clone(); - let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show( - ctx, - &style, - |ui, contents| self.window_contents(ui, window, contents, rect, &style), - ); + let opacity = self.drag_opacity(ctx, first); + let response = + FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).opacity(opacity).show( + ctx, + &style, + |ui, contents| self.window_contents(ui, window, contents, rect, &style), + ); for handle in &response.handles { self.register_interaction(ctx, handle.id); } @@ -100,6 +106,18 @@ impl Renderer<'_, P> { } } + /// Opacity of the in-window floating window whose first panel is `first`: faded + /// ([`super::DRAG_OPACITY`]) while its title bar drag highlights a drop target, as the last + /// frame drew it ([`super::preview::GROUP_HIGHLIGHTED`]), else opaque. Its tabs and drag + /// bars only drag a ghost, so the window stays opaque for them. + fn drag_opacity(&self, ctx: &Context, first: &P) -> f32 { + let moving = ctx + .data(|d| d.get_temp::>(self.id.with("group-drag"))) + .is_some_and(|drag| drag.gesture == GroupGesture::Window && drag.panel == *first); + let highlighted = ctx.data(|d| d.get_temp::(self.id.with(super::preview::GROUP_HIGHLIGHTED))).unwrap_or(false); + if moving && highlighted { super::DRAG_OPACITY } else { 1.0 } + } + /// The title bar, then the window's tree below it, in `contents`. Moves carry the whole /// `frame`, resize band included. fn window_contents(&mut self, ui: &mut Ui, window: &Floating

, contents: Rect, frame: Rect, style: &ChromeStyle) { diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index d1920287..2ceda931 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -183,7 +183,7 @@ impl Renderer<'_, P> { if usable(band) { let width = self.style.drop_line_width; let line = horizontal_line(line.clamp(view.top() + width * 0.5, view.bottom() - width * 0.5), xs, width); - self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line }); + self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line, outer: false }); } } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 78fd2dac..0f5eed6c 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -21,7 +21,7 @@ pub(in crate::docking) use native::WindowDrag; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, session, wayland}, + native::{APPKIT_CORNER, INACTIVE_PALETTE, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, drag_opacity, resize_bands, session, wayland}, }; #[cfg(all(test, feature = "native-windows"))] @@ -29,7 +29,7 @@ pub(in crate::docking) use body_drag::held_events; #[cfg(feature = "native-windows")] pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag}; #[cfg(feature = "native-windows")] -pub(in crate::docking) use native::{InputHook, set_input_hook}; +pub(in crate::docking) use native::{DesktopCursor, InputHook, send_keys, set_input_hook, typing_window}; /// How long after blocking starts a native window that still has the keyboard hands it to the /// main window: an OS dialog the app opens with the modal (a file dialog) takes focus a little @@ -37,6 +37,10 @@ pub(in crate::docking) use native::{InputHook, set_input_hook}; #[cfg(feature = "native-windows")] const BLOCKED_FOCUS_WAIT: f64 = 0.2; +/// Opacity of a floating window, in-window or native, while its bar, only tab or only tab row +/// drags it over a drop target: the highlighted target under the window shows through it. +pub(in crate::docking) const DRAG_OPACITY: f32 = 0.75; + /// While input is blocked (`starts`: from this frame), a native window of the dock area `area` /// with the keyboard would drop the keys the modal needs, so once, after /// [`BLOCKED_FOCUS_WAIT`], the main window, where the modal is, takes the keyboard if one still @@ -133,6 +137,9 @@ pub(super) struct Renderer<'a, P> { /// The other OS windows a drag may cross into. #[cfg(feature = "native-windows")] pub(super) cross: cross::Cross

, + /// Where a native window that follows a bar drag goes. + #[cfg(feature = "native-windows")] + pub(super) cursor: DesktopCursor, } pub(super) struct DrawnDivider { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 9c96401d..e7e495a0 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -1,7 +1,8 @@ //! Floating windows in their own OS windows (feature `native-windows`). Where the backend has //! viewports, every floating window whose panels allow it is drawn with -//! `show_viewport_immediate`. Palettes stay on top of other windows and hide while none of the -//! application's windows has focus; viewers are normal windows that come forward when the +//! `show_viewport_immediate`. Palettes stay on top of the application's other windows, and +//! drop to the normal level while none of its windows has focus, so they stay shown without +//! covering other applications; viewers are normal windows that come forward when the //! application is activated. Where egui embeds viewports (wasm, tests) windows stay //! in-window at their in-window rect, and so do they on Wayland, unless the app turns native //! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows @@ -25,13 +26,19 @@ use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node, Wind /// Smallest native window. const MIN_SIZE: [f32; 2] = [160.0, 100.0]; -/// How long floating windows stay after the application's last window lost focus, so focus -/// moving between our own windows does not hide them. -const HIDE_DELAY: f64 = 0.25; +/// How long none of the application's windows has focus before the application counts as +/// inactive, so focus moving between our own windows does not. +const AWAY_DELAY: f64 = 0.25; /// Whether normal windows are raised when the application is activated. macOS brings an /// application's windows forward itself: all of them on activation from the Dock or the /// application switcher, the clicked one alone on a click, as its own windows do. const RAISE_ON_ACTIVATION: bool = !cfg!(target_os = "macos"); +/// The level of a palette while the application is inactive, behind other applications' +/// windows. On Windows `WindowLevel::Normal` is `HWND_NOTOPMOST`, which puts the window in +/// front of every window that is not topmost, the activated application's too; `HWND_BOTTOM` +/// sends it behind them without activating it. +pub(in crate::docking) const INACTIVE_PALETTE: WindowLevel = + if cfg!(target_os = "windows") { WindowLevel::AlwaysOnBottom } else { WindowLevel::Normal }; /// 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, where the window has @@ -88,21 +95,16 @@ pub(in crate::docking) const MACOS_CLOSE: CloseLayout = 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 }; -/// Opacity of a native window while its bar or only tab drags it somewhere it can dock: the -/// window stays above the main window, so the drop preview under the pointer shows only -/// through it. +/// Opacity of the native window that a bar drag of the area `area` moves, by the rule for every +/// floating window ([`super::DRAG_OPACITY`]): faded while the drag highlights a drop target, as +/// the last frame drew it ([`super::cross::HIGHLIGHTED`]), opaque over empty space, for a move +/// that cannot dock and while Ctrl/Cmd keeps the drop floating. The window stays above the main +/// window, so the highlighted target shows only through it. #[cfg(any(target_os = "macos", test))] -const DRAG_OPACITY: f64 = 0.75; - -/// Opacity of the window `drag` moves: faded while the drag can dock (its only tab or group, or -/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move and while -/// Ctrl/Cmd keeps the drop floating (`float_only`). -#[cfg(any(target_os = "macos", test))] -pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, float_only: bool, title_bar_docks: bool) -> f64 { - match drag { - Some(drag) if !float_only && (drag.docks || title_bar_docks) => DRAG_OPACITY, - _ => 1.0, - } +pub(in crate::docking) fn drag_opacity(ctx: &Context, area: Id) -> f64 { + let dragging = ctx.data(|d| d.get_temp::(area.with("window-drag"))).is_some(); + let highlighted = ctx.data(|d| d.get_temp::(area.with(super::cross::HIGHLIGHTED))).unwrap_or(false); + if dragging && highlighted { f64::from(super::DRAG_OPACITY) } else { 1.0 } } /// Sets the opacity of the AppKit windows titled `title`, never one titled `main` (the main @@ -126,6 +128,48 @@ fn set_window_opacity(title: &str, main: Option<&str>, opacity: f64) { } } +/// Where the cursor is on the desktop, in egui's desktop points, for a native window that +/// follows a bar drag. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(in crate::docking) enum DesktopCursor { + /// The window system's: AppKit's on macOS, none elsewhere. + #[default] + System, + /// A stand-in window system's (`docking::testing::NativeHost`), or none. + #[cfg(any(test, feature = "testing"))] + Fixed(Option), +} + +impl DesktopCursor { + fn at(self, ctx: &Context) -> Option { + match self { + Self::System => system_cursor(ctx), + #[cfg(any(test, feature = "testing"))] + Self::Fixed(at) => at, + } + .filter(|p| p.is_finite()) + } +} + +/// AppKit's cursor, in the desktop points egui reports window positions in. AppKit measures +/// from the bottom-left of the primary screen (the one with the menu bar, first in +/// `NSScreen::screens`), y up; winit flips window positions against that screen's height, +/// whichever screen a window is on, so the cursor is flipped the same way. egui's points are +/// AppKit's divided by the zoom factor. +#[cfg(target_os = "macos")] +fn system_cursor(ctx: &Context) -> Option { + let mtm = objc2::MainThreadMarker::new()?; + let primary = objc2_app_kit::NSScreen::screens(mtm).firstObject()?; + let at = objc2_app_kit::NSEvent::mouseLocation(); + let height = primary.frame().size.height; + Some(pos2(at.x as f32, (height - at.y) as f32) / ctx.zoom_factor()) +} + +#[cfg(not(target_os = "macos"))] +fn system_cursor(_: &Context) -> Option { + None +} + /// 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 { @@ -162,8 +206,9 @@ struct Place { pending: Option, /// The window system put the frame, not the content, where it opened: corrected once. corrected: bool, - /// Shown, as it opened or was last told. - shown: bool, + /// The height of its bar while minimized, as it was last told. `known` keeps the expanded + /// size meanwhile. + collapsed: Option, } /// Whether two rects differ by more than rounding. @@ -187,12 +232,6 @@ pub(in crate::docking) fn viewport_id(area: Id, window: u64, n: usize) -> Viewpo } } -/// Whether floating windows show: while one of the application's windows has focus, and until -/// [`HIDE_DELAY`] after `last_focused`, when one last had it. -pub(in crate::docking) fn floats_shown(focused: bool, now: f64, last_focused: f64) -> bool { - focused || now - last_focused < HIDE_DELAY -} - /// The desktop rect of a native viewport's content. None when the backend reports no /// positions. fn placed(info: &ViewportInfo) -> Option<[f32; 4]> { @@ -223,7 +262,7 @@ impl Renderer<'_, P> { pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { let native = !ctx.embed_viewports() && self.native_windows.unwrap_or_else(|| !wayland(ctx, env_session())); let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); - let (visible, activated) = if native { self.float_visibility(ctx) } else { (true, false) }; + let (active, activated) = if native { self.app_activity(ctx) } else { (true, false) }; self.cross.on = native; let mut shown = Vec::new(); let mut in_window = Vec::new(); @@ -254,7 +293,7 @@ impl Renderer<'_, P> { if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { ctx.data_mut(|d| d.remove::(key)); } - self.map_desktop(ctx, &natives, visible, activated); + self.map_desktop(ctx, &natives, activated); self.raise_next(ctx, &natives); // The main window owns drags of the panels it draws. let id = self.id; @@ -269,7 +308,7 @@ impl Renderer<'_, P> { } self.map_body_route(ctx, native); for (window, rect, viewport) in natives { - if let Some(key) = self.native_window(ctx, window, rect, viewport, visible) { + if let Some(key) = self.native_window(ctx, window, rect, viewport, active) { shown.push(key); } } @@ -307,10 +346,9 @@ impl Renderer<'_, P> { /// The main window and the shown native windows on the desktop, front first: palettes /// above the rest, each the window focused most recently first. A window whose position is - /// unknown is left out. `visible`: palettes show. `activated`: the application was just - /// activated, so the main window and its normal windows are raised (see - /// [`Self::raise_next`]). - fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool, activated: bool) { + /// unknown is left out. `activated`: the application was just activated, so the main window + /// and its normal windows are raised (see [`Self::raise_next`]). + fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], activated: bool) { let key = self.id.with("native-order"); // Focus order, front first; `None` is the main window. let mut order = ctx.data(|d| d.get_temp::>>(key)).unwrap_or_default(); @@ -340,7 +378,7 @@ impl Renderer<'_, P> { Some(info) => placed(&info).map(|[x, y, w, h]| Rect::from_min_size(pos2(x, y), vec2(w, h))), }; let normal = self.normal_window(window); - if let Some(placed) = placed.filter(|r| r.is_finite() && (visible || normal)) { + if let Some(placed) = placed.filter(|r| r.is_finite()) { windows.push((window.id, placed, normal)); } } @@ -401,10 +439,11 @@ impl Renderer<'_, P> { } } - /// Whether palettes show this frame, and whether the application was just activated: one - /// of its windows has focus after none had it for long enough to hide the palettes. - /// Losing focus again stops the raise of normal windows that activation started. - fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) { + /// Whether the application is active this frame: one of its windows has focus, or one had it + /// less than [`AWAY_DELAY`] ago. And whether it was just activated: one of its windows has + /// focus after none had it for that long. Losing focus again stops the raise of normal + /// windows that activation started. + fn app_activity(&mut self, ctx: &Context) -> (bool, bool) { let (focused, now) = ctx.input(|i| { let infos = || i.raw.viewports.values(); // A backend that reports no focus at all keeps the windows. @@ -412,35 +451,34 @@ impl Renderer<'_, P> { (focused, i.time) }); self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); - let key = self.id.with("native-visibility"); + let key = self.id.with("native-last-focused"); let last = if focused { now } else { ctx.data(|d| d.get_temp::(key)).unwrap_or(now) }; - let visible = floats_shown(focused, now, last); - if visible && !focused { - ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0))); + let active = focused || now - last < AWAY_DELAY; + if active && !focused { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((AWAY_DELAY - (now - last)).max(0.0))); } ctx.data_mut(|d| d.insert_temp(key, last)); let away_key = self.id.with("native-away"); let away = ctx.data(|d| d.get_temp::(away_key)).unwrap_or(false); ctx.data_mut(|d| { - d.insert_temp(away_key, !focused && (away || !visible)); + d.insert_temp(away_key, !focused && (away || !active)); if !focused { d.remove::>>(self.id.with("native-raise")); } }); - (visible, focused && away) + (active, focused && away) } - /// A window holding a viewer is a normal window, so it neither vanishes nor sits over other - /// applications; the rest are palettes. + /// A window holding a viewer is a normal window, so it stacks with the main window; the rest + /// are palettes. fn normal_window(&self, window: &Floating

) -> bool { window.panels().any(|p| self.content.window_kind(p) == WindowKind::Normal) } - /// One floating window in its own viewport. `visible`: palettes show. Returns the key of - /// its [`Place`]. - fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visible: bool) -> Option { + /// One floating window in its own viewport. `active`: the application is active (see + /// [`Self::app_activity`]). Returns the key of its [`Place`]. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, active: bool) -> Option { let normal = self.normal_window(window); - let visible = visible || normal; let first = window.panels().next()?.clone(); let key = self.id.with(("native-place", viewport)); let now = ctx.input(|i| i.time); @@ -448,6 +486,9 @@ impl Renderer<'_, P> { let moving = |ctx: &Context| ctx.data(|d| d.get_temp::(drag_key)).is_some_and(|d| d.window == window.id); let info = ctx.input(|i| i.raw.viewports.get(&viewport).cloned()); let frame = info.as_ref().and_then(content_of).map_or(Vec2::ZERO, |(_, frame)| frame); + // A minimized window shows its bar and what `floating_body` draws below it. + let collapsed = window.minimized.then(|| BAR_HEIGHT + self.minimized_body(window)); + let shown = |[x, y, w, h]: [f32; 4]| [x, y, w, collapsed.unwrap_or(h)]; let mut place = match ctx.data(|d| d.get_temp::(key)) { Some(mut place) => { // The application changed the window's place (undo, reset, a reloaded layout): @@ -456,7 +497,7 @@ impl Renderer<'_, P> { && let Some(native) = window.native.filter(|native| differs(*native, place.known)) { let target = kept_reachable(native, &self.monitors); - place_window(ctx, viewport, target, frame); + place_window(ctx, viewport, shown(target), frame); (place.known, place.pending) = (target, Some(now + MOVE_WAIT)); } place @@ -474,9 +515,22 @@ impl Renderer<'_, P> { }; seen.insert(window.id); ctx.data_mut(|d| d.insert_temp(seen_key, seen)); - Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, shown: true } + Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, collapsed: None } } }; + // Minimized or expanded: the window keeps its top-left corner and width, and its + // height is fixed while it shows only its bar. + // The limits go first: the OS clamps the new size to the limits it has. + if place.collapsed != collapsed { + let (min, max) = match collapsed { + Some(height) => (vec2(MIN_SIZE[0], height), vec2(1_000_000.0, height)), + None => (Vec2::from(MIN_SIZE), Vec2::INFINITY), + }; + ctx.send_viewport_cmd_to(viewport, ViewportCommand::MinInnerSize(min)); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::MaxInnerSize(max)); + place_window(ctx, viewport, shown(place.known), frame); + (place.collapsed, place.pending) = (collapsed, Some(now + MOVE_WAIT)); + } let spawn = place.spawn; let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); let builder = ViewportBuilder::default() @@ -485,8 +539,14 @@ impl Renderer<'_, P> { .with_inner_size([spawn[2], spawn[3]]) .with_min_inner_size(MIN_SIZE) // A blocked palette could cover the modal, and its bar moves nothing: the main - // window can come in front of it at the normal level. - .with_window_level(if normal || self.input_blocked { WindowLevel::Normal } else { WindowLevel::AlwaysOnTop }) + // window can come in front of it at the normal level. While the application is + // inactive, its palettes stay shown behind other applications' windows + // (`INACTIVE_PALETTE`). egui sends the level only when it changes. + .with_window_level(match (normal || self.input_blocked, active) { + (true, _) => WindowLevel::Normal, + (false, true) => WindowLevel::AlwaysOnTop, + (false, false) => INACTIVE_PALETTE, + }) .with_taskbar(false); // 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 @@ -500,15 +560,9 @@ impl Renderer<'_, P> { .with_resizable(!self.input_blocked && window.panels().all(|p| self.permissions(p).movable)); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); - // A blocked palette keeps showing while the app is away: showing it on the app's return - // would raise it over the modal and, on macOS, give it focus. - let visible = visible || (self.input_blocked && place.shown); - // Only on a change: showing a shown window raises it and, on macOS, takes focus. - if place.shown != visible { - ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); - place.shown = visible; - } - if visible && !self.input_blocked && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + // While the application is inactive, raising a palette leaves focus with the application + // in use. + if (active || normal) && !self.input_blocked && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); @@ -530,6 +584,9 @@ impl Renderer<'_, P> { let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); self.cross.pointed = None; self.floating_body(ui, window, body, full); + if own && !blocked && ui.ctx().text_edit_focused() { + let _ = body_input(ui.ctx()).lock().typing_next.insert(viewport); + } self.cross.held |= ui.ctx().dragged_id().is_some_and(|id| self.owned_interactions.contains(&id)); if debug_close() { debug_outlines(ui, full, &buttons); @@ -586,20 +643,22 @@ impl Renderer<'_, P> { (place.known[0], place.known[1], place.pending) = (to.x, to.y, Some(now + MOVE_WAIT)); } let moving = moving(ctx); - // Set every frame from the drag state, so any way a drag ends (a drop, Escape, a focus - // loss, the window closing) brings the window back to full opacity. AppKit finds the - // window by title, so a window sharing the dragged one's title stays faded with it. + // Set every frame from the drag state and the last frame's preview, so leaving a target + // or any way a drag ends (a drop, Escape, a focus loss, the window closing) brings the + // window back to full opacity. AppKit finds the window by title, so a window sharing + // the dragged one's title stays faded with it. #[cfg(target_os = "macos")] { let main = ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).and_then(|v| v.title.clone())); let drag = ctx.data(|d| d.get_temp::(drag_key)); let dragged = drag.as_ref().and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); let this = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); - let float_only = drag.is_some_and(|drag| self.cross.pointer.is_some_and(|p| p.window == Some(drag.window) && p.command)); - let opacity = if this { drag_opacity(drag.as_ref(), float_only, self.style.title_bar_docks) } else { 1.0 }; + let opacity = if this { drag_opacity(ctx, self.id) } else { 1.0 }; set_window_opacity(&title, main.as_deref(), opacity); } - if let Some(placed) = report.placed.filter(|_| !moving) { + // The collapsed height a minimized window reports is not the size to keep. + let reported = report.placed.map(|[x, y, w, h]| [x, y, w, if place.collapsed.is_some() { place.known[3] } else { h }]); + if let Some(placed) = reported.filter(|_| !moving) { let waiting = place.pending.is_some_and(|until| now < until); if !differs(placed, place.known) { place.pending = None; @@ -727,21 +786,29 @@ impl Renderer<'_, P> { let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return false }; let content = ui.input(|i| content_of(i.viewport())); let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); - // A pointer move of this frame is relative to where the window was when the window - // system sent it, which is where it reports the window now: a move commanded earlier - // may not have landed yet. Without one the window stays, since the last position egui - // keeps (button events included) was measured against an older place. - let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); - let mut step = None; - if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { - let to = origin + (local.to_vec2() - drag.grab); - if to.distance(drag.to) > 0.0 { - (drag.to, step) = (to, Some(to)); - ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + // Where the window system tells the cursor on the desktop (macOS), the window goes + // there: AppKit reports the window's place as soon as a move is commanded, but measures + // the pointer events it sends against where the window server had the window, which + // can be a move behind. Elsewhere a pointer move of this frame is relative to where the + // window was when the window system sent it, which is where it reports the window now: + // a move commanded earlier may not have landed yet. Without one the window stays, since + // the last position egui keeps (button events included) was measured against an older + // place. + let to = match self.cursor.at(ui.ctx()) { + Some(cursor) => Some(cursor - drag.grab), + None => { + let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); + content.zip(moved.filter(|p| p.is_finite())).map(|((origin, _), local)| origin + (local.to_vec2() - drag.grab)) } + }; + let mut step = None; + if let Some(to) = to.filter(|to| to.distance(drag.to) > 0.0) { + (drag.to, step) = (to, Some(to)); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); } // The pointer on the desktop is the pressed point of the window where it was last - // put, so the origin is that place, less the local pointer. + // put (the cursor, where the window system tells it), so the origin is that place, + // less the local pointer. Targets are hit there. let local = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()); let origin = local.map(|local| drag.to + drag.grab - local.to_vec2()).or(content.map(|(origin, _)| origin)); let pointer = self.record_pointer(ui.ctx(), Some(window), origin, Some(&drag)); @@ -830,10 +897,11 @@ pub(super) struct NativeInput { windows: std::collections::HashMap, } -/// Each dock area's app drag in a panel body, and the releases waiting for a window's next -/// input. Kept outside [`NativeInput`]: egui calls [`egui::Plugin::input_hook`] with the plugin -/// locked, and an app's input hook may call [`crate::docking::cancel_drag`], which must reach -/// the routes without locking the plugin again. The lock here is dropped before that hook runs. +/// Each dock area's app drag in a panel body, the events waiting for a window's next input, and +/// the windows whose text fields have the keyboard. Kept outside [`NativeInput`]: egui calls +/// [`egui::Plugin::input_hook`] with the plugin locked, and an app's input hook may call +/// [`crate::docking::cancel_drag`] or [`crate::docking::send_keys`], which must reach this state +/// without locking the plugin again. The lock here is dropped before that hook runs. #[derive(Default)] pub(super) struct NativeBodyInput { /// Each dock area's app drag in a panel body. @@ -844,6 +912,14 @@ pub(super) struct NativeBodyInput { pub held: Held, /// The main window's passes so far. pub pass: u64, + /// The windows that had a focused text field in the last main-window pass, and those + /// noted so far in this one. + typing: std::collections::HashSet, + typing_next: std::collections::HashSet, + /// The main window's completed passes when `typing` was last taken from `typing_next`. + typing_at: u64, + /// Key and clipboard events for each window's next input, and the pass they were queued in. + keys: std::collections::HashMap)>, } /// The app drag routing and routed events shared by every native input hook of `ctx`. @@ -854,8 +930,8 @@ pub(super) fn body_input(ctx: &Context) -> Arc Id { Id::unique("craft-ui docking native body input") } @@ -870,7 +946,7 @@ impl egui::Plugin for NativeInput { /// Route this viewport's input without deadlocking. /// - /// Only the root viewport advances the frame pass; queued and held entries older than the + /// Only the root viewport advances the frame pass; queued, held and key entries older than the /// current pass are pruned. Events waiting for this viewport are taken before routing, so events /// enqueued by routing wait for its next input. A held release is only released once a later /// root pass exists. Earlier routed events are spliced before the window's own events, and any @@ -891,18 +967,42 @@ impl egui::Plugin for NativeInput { let pass = body.pass; body.queued.retain(|_, (at, _)| *at + 1 >= pass); body.held.retain(|_, (at, _)| *at + 1 >= pass); + body.keys.retain(|_, (at, _)| *at + 1 >= pass); + body.typing = std::mem::take(&mut body.typing_next); + body.typing_at = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + // eframe has run `App::raw_input_hook`, where an app's native menu reports its key + // equivalents (macOS: AppKit's menu takes them before the key window sees them). + // While a native window has the keyboard, the main window gets no keys of its own, + // so these are the menu's: those a focused text field there acts on are its own. + if let Some(window) = typing_window_of(&body.typing, &input.viewports) { + let (moved, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut input.events).into_iter().partition(text_key); + input.events = kept; + if !moved.is_empty() { + let entry = body.keys.entry(window).or_default(); + entry.0 = pass; + entry.1.extend(moved); + ctx.request_repaint_of(window); + } + } } // Taken before routing, which may queue more for this window's next input. A held // release waits for a later main-window pass: a discarded pass reruns within its own. let waiting = body.queued.remove(&viewport); + let keys = body.keys.remove(&viewport).map(|(_, mut keys)| { + text_clipboard_keys(ctx, viewport, &mut keys); + keys + }); let held = if body.held.get(&viewport).is_some_and(|(at, _)| *at < body.pass) { body.held.remove(&viewport) } else { None }; for route in body.routes.values_mut() { routed |= route.route(input, &mut body.queued, &mut body.held, body.pass); } // Before the window's own events: they happened earlier, and a held release must // not end a press that follows it. - let earlier = - held.into_iter().flat_map(|(_, events)| events).chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e))); + let earlier = held + .into_iter() + .flat_map(|(_, events)| events) + .chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e))) + .chain(keys.into_iter().flatten()); input.events.splice(0..0, earlier); } if routed { @@ -916,6 +1016,78 @@ impl egui::Plugin for NativeInput { } } +/// The native window in `typing` that has the keyboard, by the main window's `viewports`. +fn typing_window_of(typing: &std::collections::HashSet, viewports: &egui::ViewportIdMap) -> Option { + typing.iter().copied().find(|viewport| viewports.get(viewport).and_then(|info| info.focused) == Some(true)) +} + +/// Whether a text field acts on `event`: the clipboard events, and the command keys that copy, +/// paste, select all, undo, redo, move or delete in text. Other command keys are the app's +/// (save, close, new window, its own shortcuts). +fn text_key(event: &Event) -> bool { + match event { + Event::Copy | Event::Cut | Event::Paste(_) => true, + Event::Key { key, modifiers: m, .. } if m.command && !m.alt => match key { + Key::C | Key::X | Key::V | Key::A | Key::Backspace | Key::Delete => !m.shift, + Key::Z | Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => true, + _ => false, + }, + _ => false, + } +} + +/// A text field copies and pastes on `Copy`, `Cut` and `Paste` events, which egui-winit makes +/// from the window's own key presses. A command C, X or V press here is a menu's key +/// equivalent, which a text field ignores: give back C and X as `Copy` and `Cut`, and turn V +/// into a request for the clipboard text, which arrives in `viewport` as `Paste`. +fn text_clipboard_keys(ctx: &Context, viewport: ViewportId, events: &mut Vec) { + events.retain_mut(|e| { + let Event::Key { key, pressed: true, modifiers: m, .. } = *e else { return true }; + if !m.command || m.alt || m.shift { + return true; + } + match key { + Key::C => *e = Event::Copy, + Key::X => *e = Event::Cut, + Key::V => { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::RequestPaste); + return false; + } + _ => {} + } + true + }); +} + +/// See [`crate::docking::typing_window`]. +pub(in crate::docking) fn typing_window(ctx: &Context) -> Option { + // Between frames (in `App::raw_input_hook`) a main-window pass has ended since the swap, and + // `typing_next` is the whole of the last pass, which the next swap hands to the routing. + let passes = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + let body = body_input(ctx); + let typing = { + let input = body.lock(); + if passes > input.typing_at { input.typing_next.clone() } else { input.typing.clone() } + }; + ctx.input_for(ViewportId::ROOT, |i| typing_window_of(&typing, &i.raw.viewports)) +} + +/// See [`crate::docking::send_keys`]. +pub(in crate::docking) fn send_keys(ctx: &Context, viewport: ViewportId, events: Vec) { + if events.is_empty() { + return; + } + { + let body = body_input(ctx); + let mut input = body.lock(); + let pass = input.pass; + let entry = input.keys.entry(viewport).or_default(); + entry.0 = pass; + entry.1.extend(events); + } + ctx.request_repaint_of(viewport); +} + /// Set or clear the input hook of the dock area `area` for this frame, and forget its native /// windows until they show again. pub(in crate::docking) fn set_input_hook(ctx: &Context, area: Id, hook: Option) { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 09163c0d..65c15fd3 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -8,7 +8,7 @@ use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, dock_model::Place, surface::TAB_PADDING, - targets::{SIDE_EDGE_ZONE_DEPTH, resolve_drop_on}, + targets::{DockDrop, SIDE_EDGE_ZONE_DEPTH, resolve_drop_on}, }; use crate::layout::{SplitAxis, SplitSize}; @@ -33,6 +33,11 @@ const DOCKED_FLOAT_HEIGHT: f32 = 320.0; /// Least height of a floating window or flyout fitted to a short panel's content. pub(super) const MIN_FITTED_HEIGHT: f32 = 120.0; +/// Key of whether the area's group drag highlighted a drop target on the last frame, under the +/// area's id. An in-window floating window that its bar drag moves fades from it on the next +/// frame: the window draws before the preview. +pub(super) const GROUP_HIGHLIGHTED: &str = "group-drag-highlighted"; + /// What finishing a drag reads of the pointer, in the coordinates of the window finishing it. #[derive(Clone, Copy, Debug)] pub(super) struct DragInput { @@ -71,6 +76,7 @@ impl DragInput { impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) { let ctx = ui.ctx(); + ctx.data_mut(|d| d.remove::(self.id.with(GROUP_HIGHLIGHTED))); let key = self.id.with("group-drag"); let Some(drag) = ctx.data(|d| d.get_temp::>(key)) else { return }; if !drag.members.iter().all(|p| self.layout.contains(p) && self.permissions(p).movable) { @@ -122,7 +128,9 @@ impl Renderer<'_, P> { } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); - self.paint_group_drag(ui, &drag, pointer, float_only); + if self.paint_group_drag(ui, &drag, pointer, float_only) { + ctx.data_mut(|d| d.insert_temp(self.id.with(GROUP_HIGHLIGHTED), true)); + } } } @@ -198,11 +206,11 @@ impl Renderer<'_, P> { } /// [`crate::docking::resolve_drop_by`] with [`crate::docking::DockStyle::zone_rule`] and the - /// canvas sides of [`crate::docking::DockStyle::drop_edges`], where a split that would leave - /// a group without room becomes a join of the target group, or no target when the group - /// takes no tabs. A drop that would change only sizes is a no-op ([`Self::settled`]). + /// canvas sides of [`crate::docking::DockStyle::drop_edges`] (none in the docking profile), + /// where a split that would leave a group without room becomes a join of the target group, + /// or no target when the group takes no tabs. A drop that would change only sizes is a no-op ([`Self::settled`]). fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { - let proposal = resolve_drop_on(panel, pointer, targets, self.style.zone_rule(), self.style.drop_sides())?; + let proposal = resolve_drop_on(panel, pointer, targets, self.style.zone_rule(), self.style.canvas_sides())?; if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { return Some(self.settled(proposal, action)); } @@ -220,7 +228,7 @@ impl Renderer<'_, P> { pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), self.style.zone_rule(), - self.style.drop_sides(), + self.style.canvas_sides(), )?; Some(self.settled(proposal, action)) } @@ -396,7 +404,8 @@ impl Renderer<'_, P> { /// The ghost at the pointer (a window moving live is its own ghost) under one destination /// preview. The ghost has a translucent fill, a 2 pt outline and the members' labels. - pub(super) fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + /// Returns whether it highlighted a drop target. + pub(super) fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) -> bool { let ctx = ui.ctx(); let painter = self.preview_painter(ctx); if drag.gesture != GroupGesture::Window { @@ -418,16 +427,17 @@ impl Renderer<'_, P> { } } if float_only || (drag.gesture == GroupGesture::Window && !self.title_bar_docks()) { - return; + return false; } - if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { + if let Some((action, drop)) = self.dock_group_proposal(drag, pointer) { + let highlighted = action.is_some(); if let Some(action) = action { - self.paint_dock_preview(ctx, &painter, action, &drag.members, line); + self.paint_dock_preview(ctx, &painter, action, &drag.members, &drop); if drag.gesture != GroupGesture::Window { self.paint_float_hint(ctx, pointer); } } - return; + return highlighted; } let proposal = self.group_proposal(drag, pointer).filter(|p| !p.noop); let zones = self.paint_group_zones(&painter, drag, pointer, proposal.as_ref()); @@ -441,7 +451,9 @@ impl Renderer<'_, P> { if drag.gesture != GroupGesture::Window { self.paint_float_hint(ctx, pointer); } + return true; } + false } pub(in crate::docking) fn finish_drag(&mut self, ctx: &Context) { @@ -485,7 +497,7 @@ impl Renderer<'_, P> { return; } let dock = if float_only || outside { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; - if let Some((action, line)) = dock { + if let Some((action, drop)) = dock { if released { cancel_drag::

(ctx, self.id); if let Some(action) = action.filter(|_| pointer.distance(drag.origin) >= 2.0) { @@ -494,7 +506,7 @@ impl Renderer<'_, P> { } } else if let Some(action) = action { let painter = self.preview_painter(ctx); - self.paint_dock_preview(ctx, &painter, action, std::slice::from_ref(&drag.panel), line); + self.paint_dock_preview(ctx, &painter, action, std::slice::from_ref(&drag.panel), &drop); self.paint_float_hint(ctx, pointer); } return; @@ -568,10 +580,11 @@ impl Renderer<'_, P> { } /// The preview of a group target, for tab and group drags of `members`, which `action` moves - /// there: for a drop beside the group or the layout, the rect the members then take - /// ([`Self::result_rect`]), else the tab outline of the whole group, and the insertion marker - /// on a strip. With [`crate::docking::DockStyle::side_edge_zones`], the region the drop - /// takes, filled for a tab drag (`filled`) or a split. + /// there: for a drop beside the group, the rect the members then take + /// ([`Self::result_rect`]); on the layout's outer edge band, the line along that edge; else + /// the tab outline of the whole group, and the insertion marker on a strip. With + /// [`crate::docking::DockStyle::side_edge_zones`], the region the drop takes, filled for a + /// tab drag (`filled`) or a split. fn paint_preview( &self, ctx: &Context, @@ -582,9 +595,11 @@ impl Renderer<'_, P> { action: impl Fn(Target

) -> Action

, ) { if crate::docking::targets::usable(proposal.preview) { - let beside = matches!(proposal.placement, Placement::Split(zone) | Placement::SplitSized { zone, .. } | Placement::Edge(zone) if zone != Zone::Center); + let beside = matches!(proposal.placement, Placement::Split(zone) | Placement::SplitSized { zone, .. } if zone != Zone::Center); if self.style.side_edge_zones { drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, filled); + } else if matches!(proposal.placement, Placement::Edge(_)) { + self.paint_drop_line(ctx, proposal.preview); } else if beside { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; match self.result_rect(action(to), members) { @@ -599,12 +614,14 @@ impl Renderer<'_, P> { } /// The preview of a dock drop that `action` makes of `members`: the rect they then take - /// ([`Self::result_rect`]). A drop into an iconic column, or any dock drop with - /// [`crate::docking::DockStyle::side_edge_zones`], shows its `line`. - fn paint_dock_preview(&self, ctx: &Context, painter: &egui::Painter, action: Action

, members: &[P], line: Rect) { - match (!self.style.side_edge_zones).then_some(action).and_then(|action| self.result_rect(action, members)) { + /// ([`Self::result_rect`]). A drop on the window edge ([`DockDrop::outer`]) or into an + /// iconic column, and any dock drop with [`crate::docking::DockStyle::side_edge_zones`], + /// shows the drop's line. + fn paint_dock_preview(&self, ctx: &Context, painter: &egui::Painter, action: Action

, members: &[P], drop: &DockDrop

) { + let rect = (!self.style.side_edge_zones && !drop.outer).then_some(action).and_then(|action| self.result_rect(action, members)); + match rect { Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), - None => self.paint_drop_line(ctx, line), + None => self.paint_drop_line(ctx, drop.line), } } @@ -870,6 +887,8 @@ impl Renderer<'_, P> { let painter = self.preview_painter(ctx); if let Some(rect) = feedback.preview.filter(|rect| crate::docking::targets::usable(*rect)) { drop_preview(&painter, rect, self.style.accent, true); + // A window moving by its title bar fades over it, as over the renderer's own. + ctx.data_mut(|d| d.insert_temp(self.id.with(GROUP_HIGHLIGHTED), true)); } if let Some(rect) = feedback.insertion.filter(|rect| crate::docking::targets::usable(*rect)) { painter.rect_filled(rect, 0.0, self.style.accent); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 23b2d344..79d0666c 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -64,25 +64,30 @@ pub struct DockStyle { /// bottom edges add a full-width row, a group's zones are [`Self::side_zone_fraction`] of its /// body, and every preview is a line or a tab outline. pub side_edge_zones: bool, - /// Which sides of a group that takes no tabs (a canvas), and of the layout's outer edge, take - /// a drop (all four). Without [`Self::side_edge_zones`] the outer left and right edges are - /// new docks, so only top and bottom apply there. A dock-column app whose palettes dock only - /// at the left and right sets [`crate::docking::Sides::NONE`] with [`Self::new_dock_zones`] - /// on. Not used with [`Self::side_edge_zones`]. Groups that take tabs keep all four sides. + /// Which sides of the layout's outer edge, and of a group that takes no tabs (a canvas) + /// without [`Self::new_dock_zones`], take a drop (all four). With [`Self::new_dock_zones`] + /// a canvas body takes no drop, and without [`Self::side_edge_zones`] the outer left and + /// right edges are new docks, so only top and bottom apply there. A dock-column app whose + /// palettes dock only at the left and right sets [`crate::docking::Sides::NONE`] with + /// [`Self::new_dock_zones`] on. Not used with [`Self::side_edge_zones`]. Groups that take + /// tabs keep all four sides. pub drop_edges: super::Sides, /// The share of a group's body, below the tab strip, that means "beside" on each side: of /// its width from the left and right edges, of its height from the top and bottom edges /// (one third, at most one half). The middle joins the group as a tab. Not used with /// [`Self::side_edge_zones`]. pub side_zone_fraction: f32, - /// Width of the line that a drop between the icon groups of an iconic column, and every - /// dock drop with [`Self::side_edge_zones`], previews with, and of the line + /// Width of the line that a drop on a band along the layout's outer edge (a new dock, a new + /// outermost column or the outer edge band), a drop between the icon groups of an iconic + /// column, and every dock drop with [`Self::side_edge_zones`] preview with, and of the line /// [`crate::docking::ZoneRule::Fraction`] gives hosts as a side's preview (4 pt, more than - /// zero). Other drops beside a group, column, dock or the layout's edge preview the rect the - /// moved panels take. Border bands reach 4 pt past this line into a tab strip. + /// zero). Other drops beside a group, column or dock preview the rect the moved panels + /// take. Border bands reach 4 pt past this line into a tab strip. pub drop_line_width: f32, - /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new - /// dock (see [`Self::new_dock_band`]). + /// [D] The docking profile. The docked area's left and right edges, where no dock is yet, + /// take a drop as a new dock (see [`Self::new_dock_band`]). Without + /// [`Self::side_edge_zones`], the body of a frame or floating group that takes no tabs (a + /// canvas) takes no drop: a release there floats as over no target. pub new_dock_zones: bool, /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. pub column_bar_height: f32, @@ -237,12 +242,20 @@ impl DockStyle { } } - /// The canvas sides [`Self::drop_edges`] gives in this profile: all four with + /// The sides [`Self::drop_edges`] gives in this profile: all four with /// [`Self::side_edge_zones`]. pub(super) fn drop_sides(&self) -> super::Sides { if self.side_edge_zones { super::Sides::ALL } else { self.drop_edges } } + /// The sides of a frame or floating group that takes no tabs (a canvas) whose body takes a + /// drop: none in the docking profile ([`Self::new_dock_zones`] without + /// [`Self::side_edge_zones`]), where only the bands along borders dock beside it, else + /// [`Self::drop_sides`]. + pub(super) fn canvas_sides(&self) -> super::Sides { + if self.side_edge_zones || !self.new_dock_zones { self.drop_sides() } else { super::Sides::NONE } + } + /// The outer edges that take a drop: all four with [`Self::side_edge_zones`], else the top /// and bottom of [`Self::drop_edges`]. pub(super) fn outer_edges(&self) -> super::Sides { @@ -778,6 +791,41 @@ pub fn route_app_drag(ctx: &Context, area_id: Id) { let _ = (ctx, area_id); } +/// The native window that has the keyboard and a focused text field in its last pass (feature +/// `native-windows`; elsewhere `None`). Not a window drawn under [`DockArea::input_blocked`]. +/// +/// On macOS the menu bar takes key equivalents such as ⌘C and ⌘V before the key window sees +/// them, and an app's menu glue reports them in the main window's input. While this is `Some`, +/// the dock area moves the keys a text field acts on (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z, ⌘ with the +/// arrows, ⌘Backspace and ⌘Delete, and `Copy`, `Cut` and `Paste` events) from the main window's +/// input to that window's, as [`send_keys`] does; the app's other command keys stay with the +/// main window. A menu that runs commands itself instead of reporting keys can send the text +/// keys to this window with [`send_keys`]. An app whose own `raw_input_hook` turns the main +/// window's ⌘V into `ViewportCommand::RequestPaste` for its text fields skips that while this +/// is `Some`, so the field's window pastes instead. +pub fn typing_window(ctx: &Context) -> Option { + #[cfg(feature = "native-windows")] + return super::render::typing_window(ctx); + #[cfg(not(feature = "native-windows"))] + { + let _ = ctx; + None + } +} + +/// Give `events` to the native window `viewport` at the start of its next input, and repaint +/// it (feature `native-windows`; elsewhere it does nothing). A text field copies and pastes on +/// egui's `Copy`, `Cut` and `Paste` events, not on key presses, so ⌘C and ⌘X presses arrive as +/// `Copy` and `Cut`, and ⌘V asks the window for the clipboard text +/// (`ViewportCommand::RequestPaste`), which arrives as `Paste`. Other events arrive as they are. +/// See [`typing_window`]. +pub fn send_keys(ctx: &Context, viewport: egui::ViewportId, events: Vec) { + #[cfg(feature = "native-windows")] + super::render::send_keys(ctx, viewport, events); + #[cfg(not(feature = "native-windows"))] + let _ = (ctx, viewport, events); +} + /// One mutable application adapter owns bodies, menus, shaped text and per-panel policy, /// avoiding competing mutable closures. Logical labels are used for tab accessibility. /// [`DockArea::show_content`] reads every policy from here; the closures passed to the older @@ -965,6 +1013,8 @@ pub struct DockArea<'a, P = ()> { native_windows: Option, #[cfg(feature = "native-windows")] input_hook: Option, + #[cfg(feature = "native-windows")] + cursor: super::render::DesktopCursor, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { @@ -983,6 +1033,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { native_windows: None, #[cfg(feature = "native-windows")] input_hook: None, + #[cfg(feature = "native-windows")] + cursor: Default::default(), } } @@ -1040,6 +1092,13 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Where native windows that follow a bar drag go: a stand-in window system's cursor. + #[cfg(all(feature = "native-windows", any(test, feature = "testing")))] + pub(in crate::docking) fn desktop_cursor(mut self, cursor: super::render::DesktopCursor) -> Self { + self.cursor = cursor; + self + } + /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press /// outside it. Off, it stays open until its icon or another icon is clicked. pub fn auto_collapse_iconic(mut self, on: bool) -> Self { @@ -1216,6 +1275,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { native_windows: self.native_windows, #[cfg(feature = "native-windows")] cross: Default::default(), + #[cfg(feature = "native-windows")] + cursor: self.cursor, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new(), (root, rect)); diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 0752cbb6..2ba84165 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -4,6 +4,8 @@ use super::{Placement, Side, Target, Zone}; /// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip /// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas. +/// The dock area's docking profile ([`crate::docking::DockStyle::new_dock_zones`]) gives the +/// body of a frame or floating group that rejects tabs no target at all. #[derive(Clone, Debug)] pub struct DropTarget

{ pub anchor: P, @@ -75,9 +77,10 @@ pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Opti /// In the body below it, the outer `fraction` of its width from the left or right edge, or of /// its height from the top or bottom edge, is that side's zone; where two sides' zones meet, the /// side the pointer is relatively nearer wins. The rest is the center. Without `center` (a group -/// that takes no tabs) the whole body goes to the relatively nearest side. A `fraction` of zero -/// or less leaves only the center; more than one half acts as one half. Outside, non-finite, -/// empty and inverted rectangles have no target. +/// that takes no tabs) the whole body goes to the relatively nearest side, as the dock area has +/// it without [`crate::docking::DockStyle::new_dock_zones`]; with it, such a body is no target. +/// A `fraction` of zero or less leaves only the center; more than one half acts as one half. +/// Outside, non-finite, empty and inverted rectangles have no target. pub fn body_zone(rect: Rect, tab_height: f32, fraction: f32, center: bool, pointer: Pos2) -> Option { body_zone_on(rect, tab_height, fraction, center, Sides::ALL, pointer) } @@ -271,7 +274,7 @@ pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropT } /// [`resolve_drop_by`] where a group that takes no tabs splits only on `sides` with -/// [`ZoneRule::Fraction`]. +/// [`ZoneRule::Fraction`], and with none is no target. pub(super) fn resolve_drop_on( panel: &P, pointer: Pos2, @@ -354,12 +357,15 @@ pub(super) fn resolve_drop_on( } /// [D] A dock drop target, built once per frame next to the [`DropTarget`]s. The pointer selects -/// it inside `band`; the preview is the line `line`. +/// it inside `band`; `line` is the edge it docks to. #[derive(Clone, Debug, PartialEq)] pub(super) struct DockDrop

{ pub(super) target: DockTarget

, pub(super) band: Rect, pub(super) line: Rect, + /// A band along the window edge: a new dock, or a new column outside the outermost one. + /// Its preview is `line`, not the rect the drop takes. + pub(super) outer: bool, } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 96adf207..1fd4227b 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -140,6 +140,8 @@ impl> Do let monitors = self.monitors.clone(); #[cfg(feature = "native-windows")] let input_hook = self.input_hook.clone(); + #[cfg(feature = "native-windows")] + let cursor = self.host.as_ref().and_then(|host| host.borrow().cursor); let (style_fn, maximized, raise, auto, bounds, locked, native, blocked) = ( &self.style, self.maximized.clone(), @@ -170,6 +172,10 @@ impl> Do dock = dock.float_bounds(bounds); } #[cfg(feature = "native-windows")] + { + dock = dock.desktop_cursor(super::render::DesktopCursor::Fixed(cursor)); + } + #[cfg(feature = "native-windows")] if let Some(hook) = input_hook.clone() { dock = dock.native_input_hook(move |ctx, input| hook(ctx, input)); } @@ -249,6 +255,26 @@ impl> Do self.window_step(window, vec![Event::PointerMoved(to)]) } + /// Move the pointer to the desktop point `point` in the native window of `window`, and + /// step. The window gets the point in its coordinates, measured against its place + /// [`NativeHost::stale`] picks; with [`NativeHost::tells_cursor`] the window system also + /// tells the point as the cursor's. + #[cfg(feature = "native-windows")] + pub fn window_pointer(&mut self, window: u64, point: Pos2) -> DockOutput

{ + let viewport = self.viewport(window); + { + let mut host = self.host(); + let behind = host.pointer_moves.checked_rem(host.stale.len()).and_then(|i| host.stale.get(i)).copied().unwrap_or(0); + host.pointer_moves += 1; + host.cursor = host.tells_cursor.then_some(point); + if let Some(target) = host.windows.get_mut(&viewport) { + let place = target.places.iter().rev().nth(behind).or(target.places.first()).copied().unwrap_or(target.rect.min); + target.events.push(Event::PointerMoved(point - place.to_vec2())); + } + } + self.step(Vec::new()) + } + /// Release at `pos` in a native window, in its coordinates. #[cfg(feature = "native-windows")] pub fn window_release(&mut self, window: u64, pos: Pos2) -> DockOutput

{ @@ -400,11 +426,21 @@ pub struct NativeHost { /// `OuterPosition` lands a frame late, at the end of the frame after the command. That /// frame still reports the old place, and gets pointer events relative to it. pub lag: bool, + /// The window system tells where the cursor is on the desktop, as AppKit does: where + /// [`DockDriver::window_pointer`] last put it. + pub tells_cursor: bool, + /// [`DockDriver::window_pointer`] measures a window's pointer events against its place + /// this many frames back, one entry per call in turn, as AppKit does while the window + /// server catches up with a move it reported at once. Empty: its place now. + pub stale: Vec, pub windows: std::collections::HashMap, /// Every command sent to a native window or the main window, in order. pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, /// Moves waiting to land under `lag`: window, content position, frame sent. late: Vec<(egui::ViewportId, Pos2, usize)>, + cursor: Option, + /// Calls of [`DockDriver::window_pointer`], for [`Self::stale`]. + pointer_moves: usize, frames: usize, /// The driver's modifiers: the keyboard's, whichever window has focus. modifiers: Modifiers, @@ -434,6 +470,8 @@ pub struct HostWindow { pub dropped_files: Vec, /// Shapes of its last frame. pub shapes: Vec, + /// Desktop positions of its content at the end of recent frames, latest last. + places: Vec, } #[cfg(feature = "native-windows")] @@ -446,9 +484,13 @@ impl NativeHost { no_position: false, frame: egui::Vec2::ZERO, lag: false, + tells_cursor: false, + stale: Vec::new(), windows: Default::default(), commands: Vec::new(), late: Vec::new(), + cursor: None, + pointer_moves: 0, frames: 0, modifiers: Modifiers::NONE, screen: Rect::NOTHING, @@ -492,6 +534,7 @@ impl NativeHost { hovered_files: Vec::new(), dropped_files: Vec::new(), shapes: Vec::new(), + places: Vec::new(), }); let mut viewports = host.infos(); let (time, focused, modifiers) = (host.time, host.focus == Some(id), host.modifiers); @@ -524,8 +567,10 @@ impl NativeHost { } } - /// End a frame: moves sent in an earlier one land. + /// End a frame: moves sent in an earlier one land, and each window keeps its place. fn land(&mut self) { + /// How many frames back [`Self::stale`] can reach. + const PLACES: usize = 8; let frames = self.frames; let (landed, late) = std::mem::take(&mut self.late).into_iter().partition::, _>(|(.., sent)| *sent < frames); self.late = late; @@ -534,6 +579,11 @@ impl NativeHost { window.rect = Rect::from_min_size(pos, window.rect.size()); } } + for window in self.windows.values_mut() { + window.places.push(window.rect.min); + let excess = window.places.len().saturating_sub(PLACES); + window.places.drain(..excess); + } } /// Carry out the commands a frame sent to native windows. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 4baba14e..7a08ad25 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -215,7 +215,9 @@ A flyout fits its content the same way, without the title bar. - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags the whole group. A column bar drags a column. A floating window's title bar drags the window; with `title_bar_docks` a release over a target docks the whole window there, else it only - moves. + moves. While the drag highlights a drop target, the window is drawn at 75% opacity so the + target shows through it; over empty space, and once the drag ends, it is opaque. A native + window moved by its only tab or tab row fades the same way. - Inside a floating window, tabs and drag bars drag and dock as they do in the docked layout, into any window. A drag bar or border of a window's only group takes the whole window along, so, like the title bar, it looks through the window: the groups, docks and windows behind it @@ -239,10 +241,19 @@ A flyout fits its content the same way, without the title bar. beside the group on that side. Where two sides meet, at a corner, the side the pointer is relatively nearer wins. - The middle third joins the group's tabs. - - A group that takes no tabs, such as a protected canvas, has no middle: every point of its - body docks beside it on the relatively nearest side. Its strip takes nothing. + - A group that takes no tabs, such as a protected canvas, takes nothing on its strip, and its + body has no middle: every point docks beside it on the relatively nearest side. + - In the docking profile (`new_dock_zones` without `side_edge_zones`), the body of a frame or + floating group that takes no tabs takes nothing, whatever its `accepts_splits`: a release + there is a release over no target, so a floating window stays floating and a docked panel + or group floats as `float_on_empty_drop` says. Only the bands along borders (below) dock + beside it, as in Adobe apps, whose document area is no drop zone. A dock column's group + keeps its sides. - Beside a frame is a split of that frame. Beside a group in a dock column, the top and bottom stack a new group above or below it, and the left and right make a new column on that side. + A group dragged to an edge of its own column, such as the window edge of the column it heads, + makes a new column there, as one of its tabs would; when it is the column's only group, + nothing changes. - A frame too narrow or short for two halves of `DockStyle::min_pane` still splits when the frames beyond it can give the room, as they do for a dragged divider. When they cannot, the frame keeps its size and the new frame takes the width (or height) the moved panels have now, @@ -276,11 +287,12 @@ A flyout fits its content the same way, without the title bar. `window.panel.dock` to an outer edge picks its anchor the same way, from the permissions `apply` (or `to_actions`) gets and the area's `FloatSpace` (`with_side_edge_zones` before it has drawn). -- `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of a group that - takes no tabs (a canvas), and of the layout's outer edge, take a drop. A canvas's whole body - goes to the nearest listed side; with none listed it takes nothing. In the docking profile - the outer left and right edges are new docks, so only top and bottom apply there. Groups - that take tabs keep all four sides. A dock-column app whose palettes dock only at the left +- `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of the layout's + outer edge, and of a group that takes no tabs (a canvas) without `new_dock_zones`, take a + drop. There a canvas's whole body goes to the nearest listed side; with none listed it takes + nothing. In the docking profile the canvas body takes no drop and the outer left and right + edges are new docks, so only the outer top and bottom apply. Groups that take tabs keep all + four sides. A dock-column app whose palettes dock only at the left and right, as in AutoCAD, sets `Sides::NONE` with `new_dock_zones` on. Not used with `side_edge_zones`. - With `side_edge_zones` (the split-tree arrangement), a frame's zones are a picture frame @@ -315,15 +327,18 @@ A flyout fits its content the same way, without the title bar. way. Towards the minimums, groups give way in proportion to their room. A group takes the largest default, compact and fill height of its panels. All three are body heights, without the tab strip, like `PanelLimits`. -- Drop previews have three shapes, the same for tab and group drags: +- Drop previews have four shapes, the same for tab and group drags: - A drop beside something shows the rect the dragged panel, group or column will take, at its real position and size, as a 2 pt accent outline with a light fill. The renderer applies the drop to a copy of the layout and lays it out with the code that draws it, so the rect is the one the panel's group gets on release: the new frame for a split (including - `Placement::SplitSized`) or the outer edge band, the new group for a stack above or below - a dock group or on a seam, and the whole column, bar included, for a new column or dock. - A drop between the icon groups of an iconic column shows a `DockStyle::drop_line_width` - (4 pt) accent line there instead. + `Placement::SplitSized`), the new group for a stack above or below a dock group or on a + seam, and the whole column, bar included, for a new column between columns or beside a + dock group's side. + - A drop on a band along the layout's outer edge shows a `DockStyle::drop_line_width` (4 pt) + accent line along that edge: the new-dock band, the window edge of a dock's outermost + column, and the outer edge band (`Placement::Edge`), whose line spans the edge area it + wraps. A drop between the icon groups of an iconic column shows the same line there. - A drop that joins a group shows the same outline and fill around the whole group, and on its strip the 2 pt insertion marker. - A drop that floats shows the floating rect. @@ -462,15 +477,19 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho monitor on a second display. A window holding any `Normal` panel is a normal window, whichever of its tabs shows; the rest are palettes. - Palettes stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the document - does not bury them. While none of the app's windows has focus they hide - (`ViewportCommand::Visible(false)`), and they come back when one has it again. The hide - waits 250 ms, so focus moving between the app's own windows does not flicker. + does not bury them. They stay shown while the app is in the background, so files dragged + from another app reach them and an OS sheet attached to one stays open. While none of the + app's windows has focus they take `WindowLevel::Normal`, so they do not cover other apps + (on Windows `WindowLevel::AlwaysOnBottom`: there `Normal` would put them in front of the + app the user switched to), and they go back to `AlwaysOnTop` when one has it again. The app counts as in the + background after 250 ms without focus, so focus moving between its own windows changes + nothing. The level is sent only when it changes. - Normal windows have `WindowLevel::Normal` and stay shown while the app is in the background. They stack with the main window by focus, below every palette; a drag over a normal window that lies behind the main window targets the main window where the two overlap, and the normal window outside the main window. - Like palettes, normal windows have no taskbar entry; they come forward with the app - instead. When one of the app's windows gains focus after none had it for the hide delay, + instead. When one of the app's windows gains focus after none had it for 250 ms, the main window and each normal window get `WindowLevel::AlwaysOnTop` and then `WindowLevel::Normal`, one window per frame, in their focus order from back to front. They keep their order, nothing takes focus, and the window the user activated is raised @@ -560,9 +579,8 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho answers the OS close request with `ViewportCommand::CancelClose`. On macOS it is not resizable meanwhile. - *Stacking.* A palette drops to `WindowLevel::Normal` while blocked, so a click on the main - window brings it in front of a palette that covers the modal. A shown palette stays shown - while the app is in the background, since showing it again on the app's return would raise - it over the modal and, on macOS, give it focus. When the app is activated meanwhile, the + window brings it in front of a palette that covers the modal. It stays shown and at that + level when the app goes to the background and returns. When the app is activated meanwhile, the main window is raised last, in front of the normal windows and of a palette the user clicked. - *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window @@ -614,12 +632,17 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho (the window's top-left is the pointer minus the grab offset, less the bar's height). A Ctrl/Cmd-drag floats there wherever it is released. - *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only - group moves the window, so the pressed point stays under the pointer. Each move is worked - out from a pointer move of that frame and the window's reported place, which the pointer's - position is relative to; a move commanded earlier may land late. A frame with no pointer - move leaves the window. X11 sends no move whose position in the window repeats the last - one, so while the pointer keeps exact pace with the window the window can trail it by one - move until the next. Released over a target in another window, the whole window docks or + group moves the window, so the pressed point stays under the pointer. On macOS each frame + puts the window at the cursor's desktop position (AppKit's `NSEvent::mouseLocation`) less + the pressed point, and targets are hit at that position. AppKit reports a window's new place + as soon as a move is commanded, but measures the pointer events it sends against where the + window server had the window, which can be a move behind; placing the window from those + events would overshoot and step back. Elsewhere each move is worked out from a pointer move + of that frame and the window's reported place, which the pointer's position is relative to; + a move commanded earlier may land late. A frame with no pointer move leaves the window. X11 + sends no move whose position in the window repeats the last one, so while the pointer keeps + exact pace with the window the window can trail it by one move until the next. Released over + a target in another window, the whole window docks or joins there as its groups (`Action::MoveWindow`). The bar docks only with `DockStyle::title_bar_docks`; the only tab or group docks either way, since it is the way back in with that flag off. Targets: in the @@ -719,7 +742,10 @@ OS windows: the main window's content sits at `origin` on the desktop, and each runs its own frame with its own input and desktop rect (`host()`, `window_step`, `window_click`). `NativeHost` can report a monitor size (`monitor_size`), give windows a frame between their outer position and content (`frame`), land `OuterPosition` a frame late (`lag`), -and report no positions (`no_position`, as on Wayland). A `HostWindow` takes files dragged +and report no positions (`no_position`, as on Wayland). `window_pointer` moves the pointer to a +desktop point in a native window; the host can tell that point as the cursor's (`tells_cursor`, +as AppKit does) and measure the window's pointer events against its place some frames back +(`stale`, a pattern taken one entry per call). A `HostWindow` takes files dragged over it (`hovered_files`) or dropped on it (`dropped_files`), and `with_native_input_hook` passes a hook to `DockArea::native_input_hook` every frame.