diff --git a/crates/ui/README.md b/crates/ui/README.md index 3906429e..16bb8d95 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -11,6 +11,45 @@ The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoC records their scope and acceptance limits. Assets (font files included), complete themes, localization and command handling remain in each application. No icons, fonts or application engines are bundled. +## Host-owned docking chrome + +`DockContent` can replace an iconic column or a complete tab group without a +second layout tree. `column_rect` reports the fitted column before painting; +`owns_iconic_column`/`iconic_column` and `custom_group` suppress the corresponding +stock chrome, body and input when the host takes ownership. Docked custom groups +also skip stock body top/bottom drop bands; column-edge targets remain available. +These hooks are also +forwarded by `show_customized`'s label/permissions/limits adapter. + +`resize_handle_style` and `split_separator` let the host paint one seam while +retaining stock resize input. `owns_floating_width` delegates horizontal floating +frame-edge handles to the host; internal dividers and vertical handles remain available. +`owns_floating_split_width` separately delegates internal horizontal dividers when +the host supplies their replacement input. Presentation-only +`group_collapsed` does not change the selected tab in the shared model. + +All ownership hooks default to false/no-op. `DockStyle::dock_gap` defaults to +`None`, preserving the ordinary split gap; setting it affects side-dock seams +only. `FloatingFrame::resize_axes` defaults to both axes and preserves an enabled +axis at corners when the other is disabled. A host with its own title bar can +disable the separate top gutter through `reserve_top_resize_band(false)`. + +Compatibility and target-size audit: before these hooks, resizable frames already +reserved a 4 pt top band. That remains the default; `false` is the new host opt-out. +The unchanged stock edge hit bands are 4 pt thick; corners are at most 12 pt. +They do **not** meet a 24-by-24 logical-pixel target at 1x. This PR preserves +those hit rectangles and does not claim accessibility compliance. Focused +resize handles also support arrow keys (8 pt steps). Enlarging pointer targets +needs separate overlap, title-bar and viewport-edge acceptance checks. + +`chrome::compatibility_tests::defaults_preserve_pre_host_hook_frame_geometry` +locks down the pre-hook hit rectangles/defaults. `host_hooks_tests` exercises all +four axis settings with actual corner and side pointer drags, minimum/maximum +sizes, the viewport edge, and top-gutter opt-out geometry. + +Exhaustive `DockStyle` struct literals need `dock_gap: None`; constructors such +as `from_ui` and `from_visuals` supply the compatible default. + ## SVG catalogs ```rust diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 9e3aa2bc..3841c256 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -116,8 +116,10 @@ pub struct FloatingFrame<'a> { bounds: Option, limits: PanelLimits, resizable: bool, + resize_axes: [bool; 2], resize_label: &'a str, opacity: f32, + reserve_top_resize_band: bool, } /// What a [`FloatingFrame`] did this frame. @@ -137,7 +139,17 @@ 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", opacity: 1.0 } + Self { + id, + rect, + bounds: None, + limits: PanelLimits::default(), + resizable: true, + resize_axes: [true, true], + resize_label: "Resize window", + opacity: 1.0, + reserve_top_resize_band: true, + } } /// The frame stays inside `bounds`; the default is the viewport's content rect. @@ -153,11 +165,23 @@ impl<'a> FloatingFrame<'a> { self } + /// Reserve a separate top resize gutter; disable when contents already supply a header. + pub fn reserve_top_resize_band(mut self, reserve: bool) -> Self { + self.reserve_top_resize_band = reserve; + self + } + pub fn resizable(mut self, resizable: bool) -> Self { self.resizable = resizable; self } + /// Restrict the stock edge handles when a host owns resizing on one axis. + pub fn resize_axes(mut self, axes: [bool; 2]) -> Self { + self.resize_axes = axes; + self + } + /// Accessibility label of the resize handles. pub fn resize_label(mut self, label: &'a str) -> Self { self.resize_label = label; @@ -192,8 +216,11 @@ impl<'a> FloatingFrame<'a> { ui.set_min_size(rect.size()); ui.allocate_rect(rect, Sense::hover()); ui.painter().rect_filled(rect, 0.0, style.background); - let contents = - if self.resizable { Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) } else { rect }; + let contents = if self.resizable && self.reserve_top_resize_band { + Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) + } else { + rect + }; let inner = add_contents(ui, contents); ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; @@ -217,7 +244,14 @@ impl<'a> FloatingFrame<'a> { let mut responses = Vec::new(); let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx()); - for (name, handle, direction) in handles(rect) { + for (name, mut handle, mut direction) in handles(rect) { + if !self.reserve_top_resize_band && direction.y < 0.0 { + handle.max.y = (rect.top() + ui.ctx().pixels_per_point().recip()).min(rect.bottom()); + } + direction = vec2(if self.resize_axes[0] { direction.x } else { 0.0 }, if self.resize_axes[1] { direction.y } else { 0.0 }); + if direction == egui::Vec2::ZERO { + continue; + } if !super::targets::usable(handle) { continue; } @@ -486,3 +520,33 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt forget(); None } + +#[cfg(test)] +mod compatibility_tests { + use super::*; + + #[test] + fn defaults_preserve_pre_host_hook_frame_geometry() { + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let frame = FloatingFrame::new(Id::unique("defaults"), rect); + assert_eq!(frame.resize_axes, [true, true]); + assert!(frame.reserve_top_resize_band); + assert!(frame.resizable); + // Literal pre-PR hit rectangles: preserve these rather than silently enlarging them. + let expected = [ + [388.0, 288.0, 400.0, 300.0], + [100.0, 112.0, 104.0, 288.0], + [396.0, 112.0, 400.0, 288.0], + [112.0, 296.0, 388.0, 300.0], + [100.0, 288.0, 112.0, 300.0], + [112.0, 100.0, 388.0, 104.0], + [100.0, 100.0, 112.0, 104.0], + [388.0, 100.0, 400.0, 104.0], + ]; + for ((_, hit, _), [x0, y0, x1, y1]) in handles(rect).into_iter().zip(expected) { + assert_eq!(hit, Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))); + // Audit result: legacy targets do NOT meet a 24-by-24 logical-pixel target. + assert!(hit.width() < 24.0 || hit.height() < 24.0); + } + } +} diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs new file mode 100644 index 00000000..8415b76a --- /dev/null +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -0,0 +1,425 @@ +//! Host-owned chrome uses the same fitted geometry and preserves stock defaults. +use super::*; +use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, pos2, vec2}; + +fn input(events: Vec) -> RawInput { + RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), focused: true, events, ..Default::default() } +} +fn button(at: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn corner_resize_keeps_each_enabled_axis_including_single_axis_hosts() { + for axes in [[true, true], [false, true], [true, false], [false, false]] { + let ctx = Context::default(); + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let frame = |events| { + let mut resized = None; + ctx.run_ui(input(events), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + resized = chrome::FloatingFrame::new(Id::unique("axes"), rect).resize_axes(axes).show(ui.ctx(), &style, |_, _| ()).resized; + }) + .drop_without_applying_deltas(); + resized + }; + frame(vec![]); + frame(vec![]); + let from = rect.right_bottom() - vec2(2.0, 2.0); + frame(vec![Event::PointerMoved(from), button(from, true)]); + frame(vec![Event::PointerMoved(from + vec2(10.0, 10.0))]); + let result = frame(vec![Event::PointerMoved(from + vec2(40.0, 35.0))]); + if axes == [false, false] { + assert!(result.is_none()); + } else { + let expected = rect.size() + vec2(if axes[0] { 40.0 } else { 0.0 }, if axes[1] { 35.0 } else { 0.0 }); + assert_eq!(result, Some(Rect::from_min_size(rect.min, expected)), "{axes:?}"); + } + } +} + +#[test] +fn the_top_gutter_is_reserved_by_default_and_can_be_owned_by_the_contents() { + for reserve in [true, false] { + let ctx = Context::default(); + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let mut inner = Rect::NOTHING; + ctx.run_ui(input(vec![]), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + inner = chrome::FloatingFrame::new(Id::unique("gutter"), rect).reserve_top_resize_band(reserve).show(ui.ctx(), &style, |_, r| r).inner; + }) + .drop_without_applying_deltas(); + assert_eq!(inner.left(), rect.left()); + assert_eq!(inner.max, rect.max); + assert_eq!(inner.top(), rect.top() + if reserve { chrome::RESIZE_BAND } else { 0.0 }); + } +} + +#[derive(Default)] +struct Host { + exclusive: bool, + stock_group: Option, + bodies: usize, + columns: Vec, + iconic: Vec, + groups: std::collections::HashMap, + collapsed: bool, + protected: bool, + frame_width: bool, + split_width: bool, + space: Option>, + style_calls: std::cell::Cell, +} +impl DockContent for Host { + fn group_collapsed(&self, _: &[u32]) -> bool { + self.collapsed + } + fn permissions(&self, _: &u32) -> Permissions { + if self.protected { Permissions::PROTECTED } else { Permissions::default() } + } + fn owns_floating_split_width(&self, _: &u32) -> bool { + self.split_width + } + fn owns_floating_width(&self, _: &u32) -> bool { + self.frame_width + } + fn resize_handle_style(&self, _: crate::layout::ResizeHandleStyle) -> crate::layout::ResizeHandleStyle { + self.style_calls.set(self.style_calls.get() + 1); + crate::layout::ResizeHandleStyle { + hovered: egui::Color32::TRANSPARENT, + dragged: egui::Color32::TRANSPARENT, + focused: egui::Color32::TRANSPARENT, + } + } + fn body(&mut self, _: &mut egui::Ui, _: &u32) { + self.bodies += 1; + } + fn custom_group(&mut self, _: &mut egui::Ui, _: &[u32], selected: &u32, rect: Rect, _: bool) -> bool { + self.groups.insert(*selected, rect); + self.exclusive && self.stock_group.as_ref() != Some(selected) + } + fn owns_iconic_column(&self, _: &u32) -> bool { + self.exclusive + } + fn column_rect(&mut self, _: &Context, _: &Column, rect: Rect, _: Side) { + self.columns.push(rect); + } + fn iconic_column(&mut self, _: &mut egui::Ui, _: &Column, rect: Rect, _: Side) { + self.iconic.push(rect); + } +} +fn render(layout: &Layout, host: &mut Host) -> Vec<(Hit, Rect)> { + let ctx = Context::default(); + for _ in 0..3 { + ctx.run_ui(input(vec![]), |ui| { + host.bodies = 0; + host.columns.clear(); + host.iconic.clear(); + let mut style = DockStyle::from_ui(ui); + super::floating_tests::frame_style(&mut style); + let protected = host.protected; + let shown = DockArea::new(Id::unique("host")).show_customized( + ui, + layout, + &style, + |id| format!("Pane {id}"), + |_| if protected { Permissions::PROTECTED } else { Permissions::default() }, + |_| PanelLimits::default(), + host, + ); + assert!(shown.error.is_none(), "{:?}", shown.error); + }) + .drop_without_applying_deltas(); + } + host.space = float_space(&ctx, Id::unique("host")); + DockArea::hit_rects(&ctx, Id::unique("host")) +} + +#[test] +fn an_exclusive_group_replaces_the_stock_body_and_tab_targets_through_overrides() { + let layout = Layout { root: Some(Node::Tabs { panels: vec![1, 2], active: 0 }), floating: vec![] }; + for exclusive in [false, true] { + let mut host = Host { exclusive, ..Default::default() }; + let hits = render(&layout, &mut host); + assert_eq!(host.bodies, usize::from(!exclusive)); + assert_eq!(hits.iter().any(|(hit, _)| matches!(hit, Hit::Tab(_))), !exclusive); + } +} + +#[test] +fn an_iconic_host_receives_the_fitted_column_rect_without_a_second_stock_body() { + let mut column = Column::new(vec![Group::new(vec![1, 2], 0)], 900.0); + column.iconic = true; + let layout = Layout { root: Some(Node::Dock { side: Side::Start, columns: vec![column] }), floating: vec![] }; + let mut host = Host { exclusive: true, ..Default::default() }; + render(&layout, &mut host); + assert_eq!(host.columns.len(), 1); + assert_eq!(host.columns, host.iconic); + assert_eq!(host.bodies, 0); + let rect = host.columns[0]; + assert!(rect.width() > 0.0 && rect.right() <= 800.0 && rect.bottom() <= 600.0); +} + +#[test] +fn invalid_side_dock_gaps_are_rejected_before_host_painting() { + for gap in [f32::NAN, f32::INFINITY, -1.0, 4097.0] { + let ctx = Context::default(); + let layout = Layout { root: Some(Node::Tabs { panels: vec![1], active: 0 }), floating: vec![] }; + let mut host = Host::default(); + ctx.run_ui(input(vec![]), |ui| { + let mut style = DockStyle::from_ui(ui); + style.dock_gap = Some(gap); + let shown = DockArea::new(Id::unique("invalid-gap")).show_content(ui, &layout, &style, &mut host); + assert!(shown.error.is_some(), "{gap}"); + assert_eq!(host.bodies, 0); + }) + .drop_without_applying_deltas(); + } +} + +#[test] +fn host_owned_surfaces_record_fitted_float_geometry() { + let mut host = Host { exclusive: true, ..Default::default() }; + let layout = Layout { root: Some(Node::Tabs { panels: vec![1, 2], active: 0 }), floating: vec![] }; + render(&layout, &mut host); + let space = host.space.as_ref().unwrap(); + assert!(space.panel(&1).is_some_and(|s| s.x > 700.0 && s.y > 0.0)); + assert!(space.group(&2).is_some_and(|s| s.x > 700.0 && s.y > 0.0)); +} + +#[test] +fn host_owned_iconic_columns_record_fitted_float_geometry() { + let mut host = Host { exclusive: true, ..Default::default() }; + let mut column = Column::new(vec![Group::new(vec![1, 2], 0)], 90.0); + column.iconic = true; + let layout = Layout { root: Some(Node::Dock { side: Side::Start, columns: vec![column] }), floating: vec![] }; + render(&layout, &mut host); + let size = host.space.as_ref().unwrap().column(&1).expect("host column is recorded"); + assert_eq!(size.x, host.columns[0].width()); + assert!(size.y > 0.0); +} + +#[test] +fn a_host_collapsed_protected_group_keeps_its_selection_without_drawing_a_body() { + let layout = Layout { root: Some(Node::Tabs { panels: vec![1], active: 0 }), floating: vec![] }; + let mut host = Host { protected: true, collapsed: true, ..Default::default() }; + render(&layout, &mut host); + assert_eq!(host.bodies, 0); + assert!(matches!(layout.root, Some(Node::Tabs { active: 0, .. }))); + assert!(host.space.as_ref().unwrap().panel(&1).is_none()); +} + +#[test] +fn frame_width_ownership_keeps_internal_split_input_and_host_style() { + use crate::layout::{SplitAxis, SplitSize}; + let ctx = Context::default(); + let mut window = Floating::single(vec![1], 0, [100.0, 100.0, 500.0, 350.0]); + window.root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![1], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![2], active: 0 }), + }; + let layout = Layout { root: None, floating: vec![window] }; + let mut host = Host { frame_width: true, ..Default::default() }; + let mut step = |events| { + let mut out = DockOutput::default(); + ctx.run_ui(input(events), |ui| { + let mut style = DockStyle::from_ui(ui); + super::floating_tests::frame_style(&mut style); + out = DockArea::new(Id::unique("owned-width")).show_content(ui, &layout, &style, &mut host); + }) + .drop_without_applying_deltas(); + out + }; + step(vec![]); + step(vec![]); + step(vec![]); + let hit = Hit::Divider(Divider::WindowSplit(1, vec![])); + let hits = DockArea::::hit_rects(&ctx, Id::unique("owned-width")); + let at = hits.iter().find(|(h, _)| *h == hit).expect("internal divider remains interactive").1.center(); + step(vec![Event::PointerMoved(at), button(at, true)]); + step(vec![Event::PointerMoved(at + vec2(10.0, 0.0))]); + let out = step(vec![Event::PointerMoved(at + vec2(40.0, 0.0))]); + assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeWindowSplit { .. })), "{:?}", out.actions); + assert!(host.style_calls.get() > 0); +} + +#[test] +fn internal_width_input_is_suppressed_only_when_explicitly_owned_through_overrides() { + use crate::layout::{SplitAxis, SplitSize}; + let mut window = Floating::single(vec![1], 0, [100.0, 100.0, 500.0, 350.0]); + window.root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![1], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![2], active: 0 }), + }; + let layout = Layout { root: None, floating: vec![window] }; + for owned in [false, true] { + let mut host = Host { frame_width: true, split_width: owned, ..Default::default() }; + let hits = render(&layout, &mut host); + assert_eq!(hits.iter().any(|(hit, _)| *hit == Hit::Divider(Divider::WindowSplit(1, vec![]))), !owned); + } +} + +#[test] +fn exclusive_docked_groups_skip_stock_body_bands_but_keep_column_edges() { + for owned in [false, true] { + for (zone, anchor, inset) in [ + (Zone::Top, egui::Align2::CENTER_TOP, vec2(0.0, 50.0)), + (Zone::Bottom, egui::Align2::CENTER_BOTTOM, vec2(0.0, -20.0)), + (Zone::Left, egui::Align2::LEFT_CENTER, vec2(20.0, 0.0)), + (Zone::Right, egui::Align2::RIGHT_CENTER, vec2(-20.0, 0.0)), + ] { + let ctx = Context::default(); + let area = Id::unique("owned-dock-targets"); + let layout = Layout { + root: Some(Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![1, 2], 0)], 240.0)] }), + floating: vec![Floating::single(vec![99], 0, [500.0, 100.0, 200.0, 150.0])], + }; + let mut host = Host { exclusive: owned, stock_group: Some(99), ..Default::default() }; + let mut step = |events, start: bool| { + let mut out = DockOutput::default(); + ctx.run_ui(input(events), |ui| { + let mut style = DockStyle::from_ui(ui); + super::floating_tests::frame_style(&mut style); + out = DockArea::new(area).show_content(ui, &layout, &style, &mut host); + if start { + assert!(begin_drag(ui.ctx(), area, 99, Rect::from_min_size(pos2(500.0, 100.0), vec2(200.0, 150.0)))); + } + }) + .drop_without_applying_deltas(); + (out, host.groups.get(&1).copied()) + }; + step(vec![], false); + step(vec![], false); + let (_, target) = step(vec![], false); + let target = target.expect("the docked group is drawn"); + let from = pos2(550.0, 140.0); + step(vec![Event::PointerMoved(from), button(from, true)], true); + let to = anchor.pos_in_rect(&target) + inset; + step(vec![Event::PointerMoved(to)], false); + let (out, _) = step(vec![Event::PointerMoved(to), button(to, false)], false); + let group_drop = out.actions.iter().any(|a| { + matches!( + a, + Action::MovePanel { to: Target::At { placement: Placement::Group { .. }, .. }, .. } + | Action::MoveGroup { to: Target::At { placement: Placement::Group { .. }, .. }, .. } + ) + }); + if matches!(zone, Zone::Top | Zone::Bottom) { + assert_eq!(group_drop, !owned, "{owned} {zone:?}: {:?}", out.actions); + } else { + assert!(!out.actions.is_empty(), "column edges still work: {owned} {zone:?}"); + assert!(!group_drop, "column edges must not become group bands"); + } + } + } +} + +#[test] +fn owned_minimized_groups_skip_the_trailing_stock_target() { + for owned in [false, true] { + let ctx = Context::default(); + let area = Id::unique("owned-dock-targets"); + let mut layout = Layout { + root: Some(Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![1, 2], 0)], 240.0)] }), + floating: vec![Floating::single(vec![99], 0, [500.0, 100.0, 200.0, 150.0])], + }; + if let Some(Node::Dock { columns, .. }) = &mut layout.root { + columns[0].groups[0].minimized = true; + } + let mut host = Host { exclusive: owned, stock_group: Some(99), ..Default::default() }; + let mut step = |events, start: bool| { + let mut out = DockOutput::default(); + ctx.run_ui(input(events), |ui| { + let mut style = DockStyle::from_ui(ui); + super::floating_tests::frame_style(&mut style); + out = DockArea::new(area).show_content(ui, &layout, &style, &mut host); + if start { + assert!(begin_drag(ui.ctx(), area, 99, Rect::from_min_size(pos2(500.0, 100.0), vec2(200.0, 150.0)))); + } + }) + .drop_without_applying_deltas(); + (out, host.groups.get(&1).copied()) + }; + step(vec![], false); + step(vec![], false); + let (_, target) = step(vec![], false); + let target = target.expect("the docked group is drawn"); + let from = pos2(550.0, 140.0); + step(vec![Event::PointerMoved(from), button(from, true)], true); + let to = pos2(target.center().x, target.bottom() + 60.0); + step(vec![Event::PointerMoved(to)], false); + let (out, _) = step(vec![Event::PointerMoved(to), button(to, false)], false); + let group_drop = out.actions.iter().any(|a| { + matches!( + a, + Action::MovePanel { to: Target::At { placement: Placement::Group { .. }, .. }, .. } + | Action::MoveGroup { to: Target::At { placement: Placement::Group { .. }, .. }, .. } + ) + }); + assert_eq!(group_drop, !owned, "owned={owned}: {:?}", out.actions); + } +} + +#[test] +fn disabled_edge_axes_ignore_pointer_drags_and_enabled_edges_obey_limits() { + for axes in [[true, true], [true, false], [false, true], [false, false]] { + for horizontal in [true, false] { + for grow in [true, false] { + let ctx = Context::default(); + let rect = Rect::from_min_size(pos2(400.0, 300.0), vec2(300.0, 200.0)); + let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(720.0, 520.0)); + let frame = |events| { + let mut resized = None; + ctx.run_ui(input(events), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + resized = chrome::FloatingFrame::new(Id::unique("edge-axis"), rect) + .resize_axes(axes) + .bounds(bounds) + .limits(PanelLimits { min: vec2(160.0, 120.0), max: vec2(310.0, 210.0) }) + .show(ui.ctx(), &style, |_, _| ()) + .resized; + }) + .drop_without_applying_deltas(); + resized + }; + frame(vec![]); + frame(vec![]); + let from = if horizontal { pos2(rect.right() - 2.0, rect.center().y) } else { pos2(rect.center().x, rect.bottom() - 2.0) }; + frame(vec![Event::PointerMoved(from), button(from, true)]); + let delta = if horizontal { vec2(10.0, 0.0) } else { vec2(0.0, 10.0) }; + frame(vec![Event::PointerMoved(from + delta)]); + let result = frame(vec![Event::PointerMoved(from + delta * if grow { 100.0 } else { -100.0 })]); + let enabled = if horizontal { axes[0] } else { axes[1] }; + let expected = if horizontal { vec2(if grow { 310.0 } else { 160.0 }, 200.0) } else { vec2(300.0, if grow { 210.0 } else { 120.0 }) }; + assert_eq!(result, enabled.then(|| Rect::from_min_size(rect.min, expected)), "{axes:?} horizontal={horizontal} grow={grow}"); + } + } + } +} + +#[test] +fn frame_at_viewport_edge_cannot_resize_beyond_it() { + let ctx = Context::default(); + let rect = Rect::from_min_max(pos2(400.0, 300.0), pos2(800.0, 600.0)); + let frame = |events| { + let mut result = None; + ctx.run_ui(input(events), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + let response = chrome::FloatingFrame::new(Id::unique("viewport-edge"), rect).show(ui.ctx(), &style, |_, _| ()); + assert_eq!(response.rect, rect); + result = response.resized; + }) + .drop_without_applying_deltas(); + result + }; + frame(vec![]); + frame(vec![]); + let from = rect.max - vec2(2.0, 2.0); + frame(vec![Event::PointerMoved(from), button(from, true)]); + frame(vec![Event::PointerMoved(from + vec2(10.0, 10.0))]); + assert_eq!(frame(vec![Event::PointerMoved(from + vec2(100.0, 100.0))]), None); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b7985b82..df3349c2 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -53,6 +53,8 @@ mod floating_tests; #[cfg(test)] mod group_tests; #[cfg(test)] +mod host_hooks_tests; +#[cfg(test)] mod iconic_tests; #[cfg(all(test, feature = "native-windows"))] mod ime_tests; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 878d909e..7805ff0d 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -77,7 +77,7 @@ pub(in crate::docking) fn dock_rects

( style: &DockStyle, content: &dyn crate::docking::DockContent

, ) -> (Rect, Rect, f32) { - let gap = style.gap; + let gap = style.dock_gap.unwrap_or(style.gap); let room = (rect.width() - gap - super::node_limits(other, style, content).min.x).clamp(0.0, rect.width()); let width = columns.iter().map(|c| column_width(c, style)).sum::().clamp(0.0, room); let (dock, rest) = match edge_of(side, style.right_to_left) { @@ -266,10 +266,12 @@ impl Renderer<'_, P> { _ => return false, }; let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content); - self.dock(ui, side, columns, dock, room); path.push(dock_first); self.node(ui, other, rest, path, root); path.pop(); + // The divider's hit margin reaches into the sibling (often the canvas). Register + // the dock after that sibling so a seam press belongs to the resize handle. + self.dock(ui, side, columns, dock, room); true } @@ -328,6 +330,9 @@ impl Renderer<'_, P> { } // Registered after the columns, so the narrow hit areas win at the edges. for (index, column, first, width, inner) in drawn { + if column.iconic && self.content.owns_iconic_column(first) { + continue; + } if width < 1.0 { continue; } @@ -412,7 +417,7 @@ impl Renderer<'_, P> { } fn handle_style(&self) -> ResizeHandleStyle { - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent } + self.content.resize_handle_style(ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }) } /// Width bounds of an expanded column from its panels' limits. @@ -452,7 +457,15 @@ impl Renderer<'_, P> { if !usable(rect) { return; } + let side = side_at(edge, self.style.right_to_left); + self.content.column_rect(ui.ctx(), column, rect, side); self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect); + if column.iconic && self.content.owns_iconic_column(first) { + let mut child = ui.new_child(UiBuilder::new().id_salt(("host-iconic", first)).max_rect(rect)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + self.content.iconic_column(&mut child, column, rect, side); + return; + } let (bar, body, heights) = self.column_layout(column, rect); self.column_bar(ui, column, first, bar, rect, edge); if column.iconic && usable(body) { @@ -496,9 +509,12 @@ impl Renderer<'_, P> { /// seams and bands clipped to the visible `view`, and the height dividers. fn column_groups(&mut self, ui: &mut Ui, column: &Column

, heights: &[f32], area: Rect, view: Rect) { let rects = stacked(area, heights); - for (group, rect) in column.groups.iter().zip(&rects) { - self.group(ui, &group.panels, group.active, group.minimized, *rect, GroupKind::Dock); - } + let owned: Vec<_> = column + .groups + .iter() + .zip(&rects) + .map(|(group, rect)| self.group(ui, &group.panels, group.active, group.minimized, *rect, GroupKind::Dock)) + .collect(); let top = rects.last().map_or(area.top(), |r| r.bottom()); let xs = view.x_range(); let half = self.style.seam_band * 0.5; @@ -512,7 +528,7 @@ impl Renderer<'_, P> { } }; let mut previous: Option<&P> = None; - for (group, rect) in column.groups.iter().zip(&rects) { + for ((group, rect), owned) in column.groups.iter().zip(&rects).zip(&owned) { let Some(anchor) = group.panels.first() else { continue }; if let Some(above) = previous { let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half)); @@ -528,13 +544,14 @@ impl Renderer<'_, P> { self.dock_groups.push(DockGroup { body, center, - sides: [None, None, Some((above, line(rect.top()))), Some((below, line(rect.bottom())))], + sides: [None, None, (!*owned).then(|| (above, line(rect.top()))), (!*owned).then(|| (below, line(rect.bottom())))], }); } previous = Some(anchor); } // The room under the last group, when every group is minimized, stacks below it. if let Some(last) = previous + && !owned.last().copied().unwrap_or(false) && top < view.bottom() { let band = Rect::from_x_y_ranges(xs, top..=view.bottom()); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 256b128f..3164a462 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -54,7 +54,7 @@ impl Renderer<'_, P> { /// 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 }; + let band = if self.style.title_height > 0.0 && window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; band + self.style.title_height } @@ -81,12 +81,15 @@ impl Renderer<'_, P> { let style = ChromeStyle::from_dock(self.style); let resize_label = self.style.resize_window_label.clone(); 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), - ); + let response = FloatingFrame::new(id, rect) + .bounds(screen) + .limits(limits) + .reserve_top_resize_band(self.style.title_height > 0.0) + .resizable(movable) + .resize_axes([!self.content.owns_floating_width(first), true]) + .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); } @@ -132,7 +135,9 @@ impl Renderer<'_, P> { pub(super) fn floating_body(&mut self, ui: &mut Ui, window: &Floating

, body: Rect, frame: Rect) { let Some(key) = window.panels().next().cloned() else { return }; match (&window.root, window.minimized) { - (Node::Tabs { panels, active }, true) => self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }), + (Node::Tabs { panels, active }, true) => { + self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }); + } (_, true) => {} (root, false) => { let outer = self.tree.replace((key, frame)); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index e4e04cda..7259b534 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -36,7 +36,7 @@ const OVERFLOW_WIDTH: f32 = 22.0; const WHEEL_NOTCH: f32 = 40.0; impl Renderer<'_, P> { - /// Draw one tab group at `rect`. + /// Draw one tab group at `rect`, returning whether the host took exclusive ownership. /// /// An `active` index outside `panels` is a no-op. A lone protected panel is drawn stripless: /// its whole rect is the body and its only tab has `Rect::NOTHING`. Side splits are offered @@ -45,14 +45,23 @@ impl Renderer<'_, P> { /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. The host's extra /// tabs follow the panel tabs and fit with them ([`fit_with_extra`]). `minimized` hides the body /// but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left layout. - pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { - let Some(selected) = panels.get(active) else { return }; - self.record_float(panels, rect.size(), rect.size()); + pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) -> bool { + let Some(selected) = panels.get(active) else { return false }; + let host_collapsed = self.content.group_collapsed(panels); + let minimized = minimized || host_collapsed; + if !host_collapsed { + self.record_float(panels, rect.size(), rect.size()); + } + if self.content.custom_group(ui, panels, selected, rect, matches!(kind, GroupKind::Floating { .. })) { + return true; + } let policy = self.permissions(selected); let splits = matches!(kind, GroupKind::Frame | GroupKind::Floating { .. }) && policy.accepts_splits; let rtl = self.style.right_to_left; if stripless(panels, &*self.content) { - self.contents(ui, selected, rect); + if !minimized { + self.contents(ui, selected, rect); + } self.targets.push(DropTarget { anchor: selected.clone(), rect, @@ -62,7 +71,7 @@ impl Renderer<'_, P> { accepts_tabs: policy.accepts_tabs, right_to_left: rtl, }); - return; + return false; } let movable = panels.iter().all(|p| self.permissions(p).movable); let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); @@ -166,6 +175,7 @@ impl Renderer<'_, P> { self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); } ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + false } /// A part of the strip the host draws (`header` or `extra-tabs`) at `rect`. A press in it @@ -201,7 +211,21 @@ impl Renderer<'_, P> { if self.minimizes(owner) && response.double_clicked() { self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); } - let active = response.has_focus() || response.hovered(); + let modality = self.id.with("keyboard-chrome-focus"); + let keyboard_input = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + let pointer_input = ui.input(|i| i.pointer.any_pressed()); + let keyboard_focus = ui.ctx().data_mut(|d| { + let on = if pointer_input { + false + } else if keyboard_input { + true + } else { + d.get_temp::(modality).unwrap_or(false) + }; + d.insert_temp(modality, on); + on + }); + let active = response.has_focus() && keyboard_focus; if active { ui.painter().with_clip_rect(hit).rect_stroke( hit.shrink(1.0), @@ -392,17 +416,22 @@ impl Renderer<'_, P> { let minimized = owner.filter(|owner| self.minimizes(owner)).map(|owner| owner.minimized); let policy = self.permissions(panel); let label = self.label_of(panel); - let color = if active { self.style.text } else { self.style.inactive_text }; + let visual_active = active && !owner.is_some_and(|owner| self.content.group_collapsed(owner.panels)); + let color = if visual_active { self.style.text } else { self.style.inactive_text }; let galley = self.tab_label(ui, panel, &label, color, (rect.width() - TAB_PADDING).max(0.0)); let elided = galley.elided; let tab_id = self.id.with(("tab", panel)); let pointer_focus = self.pointer_focus(ui, tab_id); - let response = tabs::Tab::new(tab_id, &label, active) + let response = tabs::Tab::new(tab_id, &label, visual_active) .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) .focus_stroke(if pointer_focus { Stroke::NONE } else { Stroke::new(1.0, self.style.accent) }) .show_at(ui, rect, |ui, response| { let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); + painter.rect_filled( + rect, + 0.0, + if visual_active || response.hovered() { self.style.active_background } else { self.style.tab_background }, + ); let at = pos2(rect.left() + TAB_PADDING * 0.5, rect.center().y - galley.size().y * 0.5); painter.galley(at, galley.clone(), color); }); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 751e17b4..256edccd 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -212,13 +212,23 @@ impl Renderer<'_, P> { // The divider's inner-edge hit margin must win over child scrollbars, just // as accordion dividers win over the adjacent panel's content. // Named by its layout path, which `ResizeSplit` takes and hits report. + self.content.split_separator(ui, split.divider); + let host_width = + *axis == SplitAxis::Horizontal && self.tree.as_ref().is_some_and(|(panel, _)| self.content.owns_floating_split_width(panel)); + if host_width { + return; + } let layout_path = self.original_path(path); let response = ResizeHandle::new( self.split_id(&layout_path), *axis, split.divider, &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + self.content.resize_handle_style(ResizeHandleStyle { + hovered: self.style.accent, + dragged: self.style.accent, + focused: self.style.accent, + }), ) .value(split.first_size(), (split.first_size() + lo)..=(split.first_size() + hi)) .show(ui); @@ -490,11 +500,15 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont Node::Split { axis, first, second, .. } => { let a = node_limits(first, style, content); let b = node_limits(second, style, content); + let gap = if matches!(first.as_ref(), Node::Dock { .. }) || matches!(second.as_ref(), Node::Dock { .. }) { + style.dock_gap.unwrap_or(style.gap) + } else { + style.gap + }; let limits = match axis { - SplitAxis::Horizontal => PanelLimits { - min: vec2(a.min.x + b.min.x + style.gap, a.min.y.max(b.min.y)), - max: vec2(a.max.x + b.max.x + style.gap, a.max.y.min(b.max.y)), - }, + SplitAxis::Horizontal => { + PanelLimits { min: vec2(a.min.x + b.min.x + gap, a.min.y.max(b.min.y)), max: vec2(a.max.x + b.max.x + gap, a.max.y.min(b.max.y)) } + } SplitAxis::Vertical => PanelLimits { min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + style.gap), max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + style.gap), diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 8d2a1444..2dc5b7e9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -13,6 +13,8 @@ pub(super) const TAB_PADDING: f32 = 24.0; pub struct DockStyle { pub tab_height: f32, pub gap: f32, + /// Optional gap beside attached side docks. Other split/row dividers retain `gap`. + pub dock_gap: Option, pub min_pane: f32, pub background: Color32, pub tab_background: Color32, @@ -175,6 +177,7 @@ impl DockStyle { Self { tab_height: 26.0, gap: 4.0, + dock_gap: None, min_pane: 40.0, background: visuals.panel_fill, tab_background: visuals.extreme_bg_color, @@ -304,6 +307,7 @@ impl DockStyle { && self.font.size > 0.0 && self.drop_line_width > 0.0 && self.side_zone_fraction <= 0.5 + && self.dock_gap.is_none_or(|gap| gap.is_finite() && (0.0..=4096.0).contains(&gap)) } } @@ -832,6 +836,45 @@ pub fn send_keys(ctx: &Context, viewport: egui::ViewportId, events: Vec { fn body(&mut self, ui: &mut Ui, panel: &P); + + /// Observe the fitted rectangle of each attached column, before its chrome is drawn. + fn column_rect(&mut self, _ctx: &Context, _column: &super::Column

, _rect: Rect, _side: super::Side) {} + + /// An exclusive host surface replaces stock iconic painting, focus, drag and resize. + /// Placement and column-edge drop targets still belong to the workspace renderer. + fn owns_iconic_column(&self, _first: &P) -> bool { + false + } + + /// Draw the complete iconic column inside its fitted rectangle when owned by the host. + fn iconic_column(&mut self, _ui: &mut Ui, _column: &super::Column

, _rect: Rect, _side: super::Side) {} + + /// Return true after painting an exclusive group surface; stock tabs/body/targets are skipped. + /// The host supplies that group's size limits through `limits`. + fn custom_group(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _rect: Rect, _floating: bool) -> bool { + false + } + + /// Hosts which paint shared seams can keep the stock resize input without a second fill. + fn resize_handle_style(&self, default: crate::layout::ResizeHandleStyle) -> crate::layout::ResizeHandleStyle { + default + } + /// Presentation-only collapse of a group; its selected tab remains model state. + fn group_collapsed(&self, _panels: &[P]) -> bool { + false + } + /// Paint a fitted split gap in the owning surface, without another resize target. + fn split_separator(&mut self, _ui: &mut Ui, _rect: Rect) {} + /// The host supplies horizontal resize input at the floating frame edges. + /// Internal split dividers retain stock input unless `owns_floating_split_width` is true. + fn owns_floating_width(&self, _panel: &P) -> bool { + false + } + /// The host supplies horizontal resize input for every internal split in this floating tree. + /// This is separate from frame-edge ownership; only return true with replacement input. + fn owns_floating_split_width(&self, _panel: &P) -> bool { + false + } /// Plain text name for accessibility, menus and the default visual label. fn logical_label(&self, panel: &P) -> String where @@ -968,6 +1011,33 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn body(&mut self, ui: &mut Ui, panel: &P) { self.inner.body(ui, panel); } + fn column_rect(&mut self, ctx: &Context, column: &super::Column

, rect: Rect, side: super::Side) { + self.inner.column_rect(ctx, column, rect, side); + } + fn owns_iconic_column(&self, first: &P) -> bool { + self.inner.owns_iconic_column(first) + } + fn iconic_column(&mut self, ui: &mut Ui, column: &super::Column

, rect: Rect, side: super::Side) { + self.inner.iconic_column(ui, column, rect, side); + } + fn custom_group(&mut self, ui: &mut Ui, panels: &[P], active: &P, rect: Rect, floating: bool) -> bool { + self.inner.custom_group(ui, panels, active, rect, floating) + } + fn resize_handle_style(&self, default: crate::layout::ResizeHandleStyle) -> crate::layout::ResizeHandleStyle { + self.inner.resize_handle_style(default) + } + fn group_collapsed(&self, panels: &[P]) -> bool { + self.inner.group_collapsed(panels) + } + fn split_separator(&mut self, ui: &mut Ui, rect: Rect) { + self.inner.split_separator(ui, rect); + } + fn owns_floating_width(&self, panel: &P) -> bool { + self.inner.owns_floating_width(panel) + } + fn owns_floating_split_width(&self, panel: &P) -> bool { + self.inner.owns_floating_split_width(panel) + } fn logical_label(&self, panel: &P) -> String { (self.label)(panel) }