Merge pull request #470 from KotovGGG/codex/docking-host-geometry

Add opt-in docking host surfaces and fitted resize chrome
This commit is contained in:
FredApps authored and GitHub committed 2026-11-02 18:26:01 +01:00
commit ea1fbeb05b
9 files changed
+700 -35

No files matched your search

+39
View File
@@ -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
+68 -4
View File
@@ -116,8 +116,10 @@ pub struct FloatingFrame<'a> {
bounds: Option<Rect>,
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<R> {
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);
}
}
}
+425
View File
@@ -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<Event>) -> 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<u32>,
bodies: usize,
columns: Vec<Rect>,
iconic: Vec<Rect>,
groups: std::collections::HashMap<u32, Rect>,
collapsed: bool,
protected: bool,
frame_width: bool,
split_width: bool,
space: Option<FloatSpace<u32>>,
style_calls: std::cell::Cell<usize>,
}
impl DockContent<u32> 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<u32>, rect: Rect, _: Side) {
self.columns.push(rect);
}
fn iconic_column(&mut self, _: &mut egui::Ui, _: &Column<u32>, rect: Rect, _: Side) {
self.iconic.push(rect);
}
}
fn render(layout: &Layout<u32>, host: &mut Host) -> Vec<(Hit<u32>, 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::<u32>::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);
}
+2
View File
@@ -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;
+25 -8
View File
@@ -77,7 +77,7 @@ pub(in crate::docking) fn dock_rects<P>(
style: &DockStyle,
content: &dyn crate::docking::DockContent<P>,
) -> (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::<f32>().clamp(0.0, room);
let (dock, rest) = match edge_of(side, style.right_to_left) {
@@ -266,10 +266,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// seams and bands clipped to the visible `view`, and the height dividers.
fn column_groups(&mut self, ui: &mut Ui, column: &Column<P>, 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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());
+13 -8
View File
@@ -54,7 +54,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// The title bar and, for a movable (resizable) window, the resize band above it.
fn window_chrome(&self, window: &Floating<P>) -> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn floating_body(&mut self, ui: &mut Ui, window: &Floating<P>, 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));
+39 -10
View File
@@ -36,7 +36,7 @@ const OVERFLOW_WIDTH: f32 = 22.0;
const WHEEL_NOTCH: f32 = 40.0;
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<bool>(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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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);
});
+19 -5
View File
@@ -212,13 +212,23 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P>(node: &Node<P>, 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),
+70
View File
@@ -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<f32>,
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<egui::Ev
/// `show*` functions override `logical_label`, `permissions` and `limits`.
pub trait DockContent<P> {
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<P>, _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<P>, _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<P, C: DockContent<P> + ?Sized> DockContent<P> 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<P>, 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<P>, 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)
}