docking: merge floating windows, frame profile and native windows

Merge the docking-t4 track (floating title bars and stacks, frame
profile, native windows, public chrome) into the dock profile track.

Integration:
- DockStyle keeps two edge bands: `edge_band` (8) is the outer edge
  band that splits at the root; `new_dock_band` (12) is the band along
  the docked area's left and right edges that makes a new dock. One
  `seam_band` serves dock seams and floating stack seams.
- New-dock targets are offered in the dock profile rather than only in
  layouts that already have a dock.
- Window title bar drags take the dock targets: a column edge makes a
  new column, a seam or group band stacks the window's groups there,
  the new-dock band makes a dock. They still never split a frame or
  join a body centre. Tab drags check dock targets before floating
  stack and outer edge targets.
- One group_action maps column, window and group gestures to
  MoveColumn, MoveWindow and MoveGroup.
- The unused stacked_groups renderer is gone; docks and floating
  windows each draw their own stacks.
This commit is contained in:
aiongg committed 2026-10-19 05:27:17 +00:00
commit d37d9aeff3
17 files changed
+1852 -282

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"], optional = true }
[features]
serde = ["dep:serde"]
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
native-windows = []
[dev-dependencies]
egui = { workspace = true, features = ["default_fonts"] }
+347
View File
@@ -0,0 +1,347 @@
//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with a
//! grip, an optional ▾ menu button and ×. [`super::DockArea`] draws its floating windows with
//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes
//! a layout: the caller turns the responses into its own commands.
use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2};
use super::{DockStyle, PanelLimits, resize::AnchoredResize};
/// Height of a floating window's title bar.
pub const TITLE_HEIGHT: f32 = 14.0;
/// Names of the resize handles, appended to the frame id: `id.with(name)`.
pub const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"];
/// Colours and font of the frame and title bar.
#[derive(Clone, Debug)]
pub struct ChromeStyle {
/// Painted under the contents, so the window stays opaque when the contents are not.
pub background: Color32,
pub stroke: Stroke,
pub title_background: Color32,
pub text: Color32,
pub weak_text: Color32,
pub accent: Color32,
pub font: FontId,
}
impl ChromeStyle {
pub fn from_ui(ui: &Ui) -> Self {
Self::from_dock(&DockStyle::from_ui(ui))
}
pub fn from_dock(style: &DockStyle) -> Self {
Self {
background: style.floating_background,
stroke: style.floating_stroke,
title_background: style.tab_background,
text: style.text,
weak_text: style.inactive_text,
accent: style.accent,
font: style.font.clone(),
}
}
}
/// Resize handle hit rects and the edges they move: both sides, the bottom and both bottom
/// corners. The top edge belongs to the title bar.
fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] {
[
("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)),
(
"resize-left",
Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)),
vec2(-1.0, 0.0),
),
(
"resize-right",
Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)),
vec2(1.0, 0.0),
),
(
"resize-bottom",
Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())),
vec2(0.0, 1.0),
),
(
"resize-bottom-left",
Rect::from_min_max(
pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())),
pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()),
),
vec2(-1.0, 1.0),
),
]
}
/// An opaque in-window frame on its own `Order::Middle` layer, `id`, at `rect`.
pub struct FloatingFrame<'a> {
id: Id,
rect: Rect,
bounds: Option<Rect>,
limits: PanelLimits,
resizable: bool,
resize_label: &'a str,
}
/// What a [`FloatingFrame`] did this frame.
pub struct FrameResponse<R> {
pub inner: R,
/// Where the frame was drawn, inside its bounds.
pub rect: Rect,
pub layer: LayerId,
/// The rect a resize handle asks for: pointer drags anchored at the opposite edges, or
/// arrow keys on a focused handle (8 pt per press). Escape and focus loss end a resize.
pub resized: Option<Rect>,
/// The handle whose pointer resize started this frame.
pub resize_started: Option<Id>,
/// Every handle's response, for focus and gesture bookkeeping.
pub handles: Vec<Response>,
}
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" }
}
/// The frame stays inside `bounds`; the default is the viewport's content rect.
pub fn bounds(mut self, bounds: Rect) -> Self {
self.bounds = Some(bounds);
self
}
/// Size limits of a resize. The frame itself is drawn at the given rect.
pub fn limits(mut self, limits: PanelLimits) -> Self {
self.limits = limits;
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
/// Accessibility label of the resize handles.
pub fn resize_label(mut self, label: &'a str) -> Self {
self.resize_label = label;
self
}
pub fn layer(id: Id) -> LayerId {
LayerId::new(Order::Middle, id)
}
/// Draw the frame, then `add_contents` with the frame's rect, then the outline and handles.
pub fn show<R>(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse<R> {
let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect());
let rect = clamp(self.rect, bounds);
let area = egui::Area::new(self.id)
.fade_in(false)
.order(Order::Middle)
.fixed_pos(rect.min)
.default_size(rect.size())
.movable(false)
.constrain_to(bounds);
let shown = area.show(ctx, |ui| {
// 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());
ui.painter().rect_filled(rect, 0.0, style.background);
let inner = add_contents(ui, rect);
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()) };
FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles }
});
shown.inner
}
fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option<Rect>, Option<Id>, Vec<Response>) {
let mut resized = None;
let mut started = None;
let mut responses = Vec::new();
let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits };
let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused);
for (name, handle, direction) in handles(rect) {
if !super::targets::usable(handle) {
continue;
}
let cursor = if direction.x == 0.0 {
egui::CursorIcon::ResizeVertical
} else if direction.y == 0.0 {
egui::CursorIcon::ResizeHorizontal
} else if direction.x == direction.y {
egui::CursorIcon::ResizeNwSe
} else {
egui::CursorIcon::ResizeNeSw
};
let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.resize_label));
let anchor_id = response.id.with("pointer-anchor");
if response.drag_started() {
response.request_focus();
if let Some(origin) = ui.input(|i| i.pointer.press_origin())
&& let Some(anchor) = AnchoredResize::new(rect, origin, direction)
{
ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor));
started = Some(response.id);
}
}
let mut delta = Vec2::ZERO;
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(
response.id,
egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() },
)
});
ui.input_mut(|input| {
for (key, step) in [
(Key::ArrowLeft, vec2(-8.0, 0.0)),
(Key::ArrowRight, vec2(8.0, 0.0)),
(Key::ArrowUp, vec2(0.0, -8.0)),
(Key::ArrowDown, vec2(0.0, 8.0)),
] {
if input.consume_key(egui::Modifiers::NONE, key) {
delta += step;
}
}
});
}
let next = if response.dragged() || response.drag_stopped() {
ui.ctx()
.data(|data| data.get_temp::<AnchoredResize>(anchor_id))
.and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, bounds, limits)))
} else if delta != Vec2::ZERO {
AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, bounds, limits))
} else {
None
};
if response.enabled()
&& !cancelled
&& let Some(next) = next.filter(|next| *next != rect)
{
resized = Some(next);
}
if response.drag_stopped() {
response.surrender_focus();
}
if cancelled || !ui.input(|input| input.pointer.primary_down()) {
ui.ctx().data_mut(|data| data.remove::<AnchoredResize>(anchor_id));
}
if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) {
ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent));
}
responses.push(response);
}
(resized, started, responses)
}
}
/// Keep `rect` inside `bounds`, shrinking it when it is larger.
pub fn clamp(rect: Rect, bounds: Rect) -> Rect {
if !super::targets::usable(bounds) {
return rect;
}
let size = rect.size().max(vec2(1.0, 1.0)).min(bounds.size());
let min = rect.min.max(bounds.min).min(bounds.max - size);
Rect::from_min_size(min, size)
}
/// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt
/// in from the right edge. The bar senses clicks and drags; double-clicks are the caller's.
pub struct TitleBar<'a> {
id: Id,
move_label: &'a str,
title: Option<WidgetText>,
menu: Option<&'a str>,
close: Option<&'a str>,
movable: bool,
}
/// The parts of a [`TitleBar`] that were shown.
pub struct TitleResponse {
/// Drag to move; double-click is the caller's (docking minimizes every group).
pub bar: Response,
pub menu: Option<Response>,
pub close: Option<Response>,
}
impl<'a> TitleBar<'a> {
/// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`.
/// `move_label` names the bar for accessibility.
pub fn new(id: Id, move_label: &'a str) -> Self {
Self { id, move_label, title: None, menu: None, close: None, movable: true }
}
/// Text at the left instead of the grip lines.
pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
self.title = Some(title.into());
self
}
/// Show a ▾ button with this accessibility label.
pub fn menu(mut self, label: &'a str) -> Self {
self.menu = Some(label);
self
}
/// Show a × button with this accessibility label.
pub fn close(mut self, label: &'a str) -> Self {
self.close = Some(label);
self
}
/// Whether the bar shows the grab cursor. It senses drags either way.
pub fn movable(mut self, movable: bool) -> Self {
self.movable = movable;
self
}
pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, style.title_background);
let side = rect.height();
let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)));
let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0);
let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom())));
let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left());
let bar_rect = Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom()));
let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag());
let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar };
bar.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.move_label));
if let Some(title) = self.title {
let galley = title.into_galley(ui, Some(egui::TextWrapMode::Truncate), (bar_rect.width() - 12.0).max(0.0), style.font.clone());
painter.galley(pos2(rect.left() + 6.0, rect.center().y - galley.size().y * 0.5), galley, style.text);
} else {
let grip = Stroke::new(0.6, style.weak_text);
for k in 0..2 {
let y = rect.center().y - 1.5 + k as f32 * 3.0;
painter.line_segment([pos2(rect.center().x - 9.0, y), pos2(rect.center().x + 9.0, y)], grip);
}
}
if bar.has_focus() {
painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside);
}
let button = |ui: &mut Ui, rect: Rect, name: &str, label: &str| {
let response = ui.interact(rect, self.id.with(name), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
let color = if response.hovered() || response.has_focus() { style.text } else { style.weak_text };
(response, Stroke::new(1.2, color))
};
let menu = menu_rect.zip(self.menu).map(|(menu_rect, label)| {
let (response, stroke) = button(ui, menu_rect, "menu", label);
let c = menu_rect.center();
painter.add(egui::Shape::convex_polygon(vec![c + vec2(-3.5, -1.5), c + vec2(3.5, -1.5), c + vec2(0.0, 2.5)], stroke.color, Stroke::NONE));
response
});
let close = close_rect.zip(self.close).map(|(close_rect, label)| {
let (response, stroke) = button(ui, close_rect, "close", label);
let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0)));
painter.line_segment([r.left_top(), r.right_bottom()], stroke);
painter.line_segment([r.right_top(), r.left_bottom()], stroke);
response
});
TitleResponse { bar, menu, close }
}
}
+37 -1
View File
@@ -6,7 +6,7 @@
use std::hash::Hash;
use super::model::{DockError, Floating, Layout, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width};
use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width};
use crate::layout::{SplitAxis, SplitSize};
/// Width of a new column when nothing better is known.
@@ -242,6 +242,25 @@ impl<P: Clone + Eq + Hash> Layout<P> {
None
}
/// The node an edge split wraps: the root, or the part between the docks.
pub(in crate::docking) fn edge_path(&self) -> Vec<bool> {
let mut path = Vec::new();
let mut node = self.root.as_ref();
while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node {
if path.len() > MAX_DEPTH {
break;
}
let second_side = match (&**first, &**second) {
(Node::Dock { .. }, _) => true,
(_, Node::Dock { .. }) => false,
_ => break,
};
path.push(second_side);
node = Some(if second_side { second } else { first });
}
path
}
pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec<Column<P>>> {
match self.root.as_mut().and_then(|root| node_at_mut(root, path)) {
Some(Node::Dock { columns, .. }) => Some(columns),
@@ -494,6 +513,23 @@ impl<P: Clone + Eq + Hash> Layout<P> {
groups.splice(index..index, moving.groups);
Ok(())
}
Placement::Edge(zone) => {
if zone == Zone::Center || matches!(place, Place::Floating { .. }) {
return Err(DockError::InvalidPlacement);
}
let path = self.edge_path();
let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?;
if matches!(node, Node::Dock { .. }) {
return Err(DockError::InvalidPlacement);
}
let (panels, active) = flatten(moving.groups);
let new = Node::Tabs { panels, active };
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let (first, second, size) = if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) };
*node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) };
Ok(())
}
Placement::Column { after } => {
let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) };
let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?;
+561
View File
@@ -0,0 +1,561 @@
//! Floating windows (title bar, stacks, close), the frame profile (outer edge, Maximize,
//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome.
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, Vec2, pos2, vec2};
const AREA: &str = "floating-area";
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
/// `[1] | [2, 3]` with panel 3 selected.
fn frames() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(300.0),
first: Box::new(tabs(&[1], 0)),
second: Box::new(tabs(&[2, 3], 1)),
}),
floating: Vec::new(),
}
}
/// A start dock (`[2, 3]` above `[4]`) beside protected canvas 0; window W1 stacks `[5]` above
/// `[6, 7]`; window W2 holds `[8]`.
fn workspace() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(240.0),
first: Box::new(Node::Dock {
side: Side::Start,
columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)],
}),
second: Box::new(tabs(&[0], 0)),
}),
floating: vec![
Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [300.0, 80.0, 240.0, 300.0], native: None },
Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]),
],
}
}
#[derive(Default)]
struct Content {
native: bool,
keep: Vec<u32>,
}
impl DockContent<u32> for Content {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
ui.label("Body");
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn permissions(&self, panel: &u32) -> Permissions {
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
}
fn allow_native(&self, _: &u32) -> bool {
self.native
}
}
#[derive(Clone, Copy, PartialEq)]
enum Hide {
None,
All,
Keep,
}
struct Driver {
ctx: Context,
layout: Layout<u32>,
content: Content,
profile: Profile,
maximized: Option<u32>,
hide: Hide,
modifiers: Modifiers,
time: f64,
}
impl Driver {
fn new(layout: Layout<u32>, profile: Profile) -> Self {
let mut driver = Self {
ctx: Context::default(),
layout,
content: Content::default(),
profile,
maximized: None,
hide: Hide::None,
modifiers: Modifiers::NONE,
time: 0.0,
};
driver.step(vec![]);
driver.step(vec![]);
driver
}
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
events.insert(0, Event::ModifiersChanged(self.modifiers));
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
events,
focused: true,
time: Some(self.time),
..Default::default()
};
let (layout, content, profile, maximized, hide) = (&self.layout, &mut self.content, self.profile, self.maximized, self.hide);
let keep: Vec<u32> = content.keep.clone();
let keep = move |p: &u32| keep.contains(p);
let full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
style.profile = profile;
let hide = match hide {
Hide::None => HidePanels::None,
Hide::All => HidePanels::All,
Hide::Keep => HidePanels::Except(&keep),
};
output = DockArea::new(Id::new(AREA)).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content);
});
let mut full = full;
full.textures_delta.clear();
output
}
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
let output = self.frame(events);
assert!(output.error.is_none(), "{output:?}");
for action in &output.actions {
self.layout.apply_with_permissions(action.clone(), |p| self.content.permissions(p)).unwrap();
}
output
}
fn response(&self, id: Id) -> Option<Rect> {
self.ctx.read_response(id).map(|r| r.rect)
}
fn title(&self, first: u32) -> Rect {
self.response(Id::new(AREA).with(("move-group", Some(&first)))).unwrap()
}
fn tab(&self, panel: u32) -> Option<Rect> {
self.response(Id::new(AREA).with(("tab", &panel)))
}
fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(from), button(from, true)]);
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
self.step(vec![Event::PointerMoved(to)])
}
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
self.hold(from, to);
self.step(vec![button(to, false)])
}
fn click(&mut self, at: egui::Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(at), button(at, true)]);
self.step(vec![button(at, false)])
}
fn wait(&mut self) {
for _ in 0..45 {
self.step(vec![]);
}
}
}
fn done(mut full: egui::FullOutput) {
full.textures_delta.clear();
}
fn button(pos: egui::Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn window_of(layout: &Layout<u32>, panel: u32) -> Option<&Floating<u32>> {
layout.floating.iter().find(|w| w.contains(&panel))
}
fn groups_of(window: &Floating<u32>) -> Vec<Vec<u32>> {
window.groups.iter().map(|g| g.panels.clone()).collect()
}
#[test]
fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() {
// The window follows the pointer with every group; Escape puts it back.
let mut d = Driver::new(workspace(), Profile::Dock);
let title = d.title(5).center();
d.hold(title, title + vec2(40.0, 30.0));
assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]);
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![5], vec![6, 7]]);
d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
d.step(vec![button(title + vec2(40.0, 30.0), false)]);
assert_eq!(d.layout, workspace());
// Released over another window's lower band: its groups stack below that window's.
let out = d.drag(title, pos2(660.0, 480.0));
assert!(
out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true } } }),
"{:?}",
out.actions
);
assert_eq!(d.layout.floating.len(), 1);
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]);
// Released over a docked group's strip: every panel joins it as tabs.
let mut d = Driver::new(workspace(), Profile::Dock);
let title = d.title(5).center();
let strip = d.tab(4).unwrap().center();
let out = d.drag(title, strip);
assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 4, .. } })), "{:?}", out.actions);
assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 7).is_none());
assert_eq!(d.layout.place(&4), d.layout.place(&6));
// Released over the canvas: the window stays where it was moved and hosts get the release.
let mut d = Driver::new(workspace(), Profile::Dock);
let title = d.title(5).center();
let out = d.drag(title, pos2(500.0, 300.0));
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions);
assert_eq!(out.group_drop, Some((5, pos2(500.0, 300.0))));
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]);
}
#[test]
fn floating_stack_seams_and_bands_insert_groups() {
// A group dragged by its bar to another window's top band stacks above that group.
let mut d = Driver::new(workspace(), Profile::Dock);
let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center();
let out = d.drag(bar, pos2(660.0, 360.0));
assert!(
out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false } } }),
"{:?}",
out.actions
);
assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]);
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]);
// A tab dropped on the seam between two stacked groups becomes a group between them.
let mut d = Driver::new(workspace(), Profile::Dock);
let tab = d.tab(8).unwrap().center();
let seam = pos2(420.0, 237.0);
let out = d.drag(tab, seam);
assert!(out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), "{:?}", out.actions);
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]);
// The divider between two open groups resizes both.
let mut d = Driver::new(workspace(), Profile::Dock);
let divider = d.response(Id::new(AREA).with(("floating-divider", Some(&5u32)))).unwrap().center();
d.drag(divider, divider + vec2(0.0, 40.0));
let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect();
assert!((heights[0] - 181.5).abs() < 2.0 && (heights[1] - 101.5).abs() < 2.0, "{heights:?}");
}
#[test]
fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() {
let mut d = Driver::new(workspace(), Profile::Dock);
let title = d.title(5).center();
d.click(title);
d.click(title);
assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| g.minimized));
d.wait();
d.click(title);
d.click(title);
assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| !g.minimized));
let close = d.response(Id::new(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap();
assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge");
let before = d.layout.clone();
let out = d.click(close.center());
assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]);
assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7]));
assert!(window_of(&d.layout, 6).is_none() && d.layout.contains(&8));
}
#[test]
fn closed_floating_window_reopens_each_panel_at_its_last_rect() {
let mut layout = workspace();
let locations: Vec<_> = [5, 6, 7].map(|p| (p, layout.location(&p).unwrap())).into();
layout.apply(Action::CloseWindow { panel: 6 }).unwrap();
for (panel, location) in locations {
layout.restore(panel, &location).unwrap();
assert_eq!(window_of(&layout, panel).unwrap().rect, [300.0, 80.0, 240.0, 300.0], "{panel}");
}
}
#[test]
fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() {
let mut d = Driver::new(frames(), Profile::Frame);
let tab = d.tab(3).unwrap().center();
let other = d.tab(1).unwrap().center();
d.modifiers = Modifiers::COMMAND;
d.hold(tab, other);
d.modifiers = Modifiers::NONE;
let out = d.step(vec![button(other, false)]);
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
assert!(window_of(&d.layout, 3).is_some());
// Without the modifier the same drag joins the other frame.
let mut d = Driver::new(frames(), Profile::Frame);
let out = d.drag(tab, other);
assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions);
}
#[test]
fn outer_edge_band_splits_at_the_root() {
// Frame profile: all four edges.
let mut d = Driver::new(frames(), Profile::Frame);
let tab = d.tab(3).unwrap().center();
let out = d.drag(tab, pos2(3.0, 300.0));
assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions);
let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
assert_eq!(**first, tabs(&[3], 0));
// Dock profile: the bottom edge of the part between the docks, for a Timeline.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock);
let tab = d.tab(2).unwrap().center();
let out = d.drag(tab, pos2(500.0, 597.0));
assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions);
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
assert!(matches!(**first, Node::Dock { .. }));
assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0)));
// Its left and right edges are the docks' (new columns and docks), not root splits.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock);
let tab = d.tab(4).unwrap().center();
let out = d.drag(tab, pos2(246.0, 300.0));
assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions);
}
#[test]
fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() {
let mut layout = frames();
layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0]));
let mut d = Driver::new(layout.clone(), Profile::Frame);
let window_title = d.title(5).center();
d.maximized = Some(2);
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.tab(2).unwrap().left(), 0.0, "the maximized frame starts at the area's edge");
assert!(d.tab(1).is_none());
assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none());
let out = d.drag(window_title, window_title + vec2(30.0, 30.0));
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions);
// A floating panel does not maximize.
let mut d = Driver::new(layout, Profile::Frame);
d.maximized = Some(5);
d.step(vec![]);
d.step(vec![]);
assert!(d.tab(1).is_some());
}
#[test]
fn close_other_panels_keeps_only_the_chosen_tab() {
use egui_kittest::{Harness, kittest::Queryable};
type State = (Content, Vec<Action<u32>>, Vec<DockEvent<u32>>);
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, (content, actions, events): &mut State| {
let mut style = DockStyle::from_ui(ui);
style.profile = Profile::Frame;
let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content);
actions.extend(out.actions);
events.extend(out.events);
},
(Content::default(), Vec::new(), Vec::new()),
);
h.run_steps(2);
let at = h.ctx.read_response(Id::new(AREA).with(("tab", &3u32))).unwrap().rect.center();
let open_menu = |h: &mut Harness<'_, State>| {
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
};
open_menu(&mut h);
for label in ["Undock Panel", "Close Panel Group", "Undock Panel Group", "Close Other Panels in Group", "Maximize Frame"] {
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
}
assert_eq!(h.query_all_by_label("Minimize").count(), 0);
h.get_by_label("Close Other Panels in Group").click();
h.run_steps(2);
assert_eq!(h.state().1, vec![Action::CloseOthers { panel: 3 }]);
let mut layout = frames();
layout.apply(Action::CloseOthers { panel: 3 }).unwrap();
assert!(layout.contains(&3) && !layout.contains(&2));
open_menu(&mut h);
h.get_by_label("Maximize Frame").click();
h.run_steps(2);
assert_eq!(h.state().2, vec![DockEvent::ToggleMaximize { panel: 3 }]);
}
#[test]
fn hide_panels_keeps_protected_panels_and_the_except_set() {
let mut d = Driver::new(workspace(), Profile::Dock);
d.hide = Hide::All;
d.step(vec![]);
d.step(vec![]);
for panel in [2, 3, 4, 5, 6, 8] {
assert!(d.tab(panel).is_none(), "{panel}");
}
assert!(d.layout == workspace(), "hiding leaves the layout unchanged");
d.hide = Hide::Keep;
d.content.keep = vec![4, 6];
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.tab(4).map(|r| r.left()), Some(0.0));
assert!(d.tab(6).is_some());
for panel in [2, 3, 5, 7, 8] {
assert!(d.tab(panel).is_none(), "{panel}");
}
// A window keeps its rect with its hidden groups left out.
d.step(vec![]);
assert_eq!(d.title(6).min, pos2(300.0, 80.0));
}
#[test]
fn native_window_falls_back_in_window_when_viewports_are_embedded() {
let mut layout = workspace();
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
let mut d = Driver::new(layout.clone(), Profile::Dock);
d.content.native = true;
assert!(d.ctx.embed_viewports());
let out = d.step(vec![]);
assert!(out.actions.is_empty(), "{out:?}");
assert_eq!(d.title(5).min, pos2(300.0, 80.0));
assert_eq!(d.layout, layout);
}
#[cfg(feature = "native-windows")]
#[test]
fn native_window_close_returns_the_group_to_its_in_window_rect() {
use std::{cell::Cell, rc::Rc};
let close = Rc::new(Cell::new(false));
let requested = close.clone();
// A backend with real viewports: the child reports its place and, when asked, a close.
Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
let mut info = egui::ViewportInfo {
outer_rect: Some(Rect::from_min_size(pos2(1210.0, 905.0), vec2(250.0, 330.0))),
inner_rect: Some(Rect::from_min_size(pos2(1210.0, 935.0), vec2(250.0, 300.0))),
..Default::default()
};
if requested.get() {
info.events.push(egui::ViewportEvent::Close);
}
let input = RawInput {
viewport_id: viewport.ids.this,
viewports: std::iter::once((viewport.ids.this, info)).collect(),
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(250.0, 300.0))),
..Default::default()
};
done(ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)));
});
let mut layout = workspace();
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
let mut d = Driver::new(Layout::default(), Profile::Dock);
d.ctx.set_embed_viewports(false);
d.content.native = true;
d.layout = layout;
let out = d.step(vec![]);
assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window");
assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 905.0, 250.0, 300.0]) }), "{:?}", out.actions);
close.set(true);
let out = d.step(vec![]);
assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: None }), "{:?}", out.actions);
assert_eq!(d.layout.floating[0].native, None);
assert_eq!(d.layout.floating[0].rect, [300.0, 80.0, 240.0, 300.0]);
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.title(5).min, pos2(300.0, 80.0));
}
#[test]
fn chrome_frame_and_title_bar_work_without_a_dock_area() {
use chrome::{ChromeStyle, FloatingFrame, TitleBar};
let ctx = Context::default();
let id = Id::new("task-pane");
let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0));
let mut time = 0.0;
let mut frame = |events: Vec<Event>| {
time += 1.0 / 60.0;
let input =
RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() };
let mut result = None;
done(ctx.run_ui(input, |ui| {
let style = ChromeStyle::from_ui(ui);
let shown = FloatingFrame::new(id, rect).show(ui.ctx(), &style, |ui, inner| {
let title = Rect::from_min_size(inner.min, vec2(inner.width(), 22.0));
let bar = TitleBar::new(id.with("title"), "Move pane").title("Styles").menu("Pane menu").close("Close pane").show(ui, title, &style);
(bar.bar.dragged(), bar.bar.drag_delta(), bar.menu.is_some_and(|r| r.clicked()), bar.close.is_some_and(|r| r.clicked()))
});
result = Some((shown.inner, shown.resized));
}));
result.unwrap()
};
frame(vec![]);
frame(vec![]);
type Shown = ((bool, Vec2, bool, bool), Option<Rect>);
let click = |frame: &mut dyn FnMut(Vec<Event>) -> Shown, at| {
frame(vec![Event::PointerMoved(at), button(at, true)]);
frame(vec![button(at, false)])
};
let ((_, _, _, closed), _) = click(&mut frame, pos2(390.0, 111.0));
assert!(closed, "× 10 pt in from the right");
let ((_, _, menu, _), _) = click(&mut frame, pos2(374.0, 111.0));
assert!(menu, "▾ left of ×");
frame(vec![Event::PointerMoved(pos2(200.0, 110.0)), button(pos2(200.0, 110.0), true)]);
frame(vec![Event::PointerMoved(pos2(210.0, 115.0))]);
let ((dragged, delta, ..), _) = frame(vec![Event::PointerMoved(pos2(230.0, 125.0))]);
assert!(dragged && delta == vec2(20.0, 10.0), "{delta:?}");
frame(vec![button(pos2(230.0, 125.0), false)]);
let edge = pos2(398.0, 200.0);
frame(vec![Event::PointerMoved(edge), button(edge, true)]);
frame(vec![Event::PointerMoved(edge + vec2(10.0, 0.0))]);
let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]);
assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0))));
}
#[test]
fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() {
let layout = Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(200.0),
first: Box::new(tabs(&[9], 0)),
second: Box::new(Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.5),
first: Box::new(tabs(&[1], 0)),
second: Box::new(tabs(&[2], 0)),
}),
}),
floating: Vec::new(),
};
let mut d = Driver::new(layout, Profile::Frame);
d.hide = Hide::Keep;
d.content.keep = vec![1, 2];
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others");
let divider = d.response(Id::new(AREA).with(("split", &Vec::<bool>::new()))).unwrap().center();
let out = d.hold(divider, divider + vec2(0.0, 60.0));
assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions);
let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5)));
}
+1 -1
View File
@@ -527,7 +527,7 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() {
let mut d = Driver::new(layout);
let from = d.rect("drag-bar", 6).center();
let out = d.drag(from, pos2(700.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 200.0]) })), "{:?}", out.actions);
assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 191.5]) })), "{:?}", out.actions);
assert_eq!(d.layout.floating.len(), 2);
assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]);
assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]);
+7 -1
View File
@@ -5,6 +5,7 @@
//! Validate deserialized layouts before rendering them. Every transaction checks its complete
//! input and commits only a valid result; a rejected move cannot lose the source panel.
pub mod chrome;
mod dock_model;
mod model;
mod render;
@@ -15,7 +16,10 @@ mod workspace;
pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target};
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag, is_dragging};
pub use surface::{
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag,
is_dragging,
};
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect};
pub use workspace::Workspace;
@@ -24,6 +28,8 @@ use surface::{Drag, GroupDrag, GroupGesture};
#[cfg(test)]
mod dock_tests;
#[cfg(test)]
mod floating_tests;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod model_tests;
+11 -6
View File
@@ -169,6 +169,10 @@ pub enum Placement<P> {
Group { below: bool },
/// A new column before (towards the window edge) or after the anchor's column.
Column { after: bool },
/// A new frame along one edge of the docked layout, a quarter of its width or height: the
/// root is split, or in a layout with docks, the part between them. The anchor names any
/// docked panel. `Center` is invalid.
Edge(Zone),
}
/// A closed panel's former placement. Applications may retain this beside visibility flags.
@@ -572,7 +576,8 @@ impl<P: Clone + Eq + Hash> Layout<P> {
}
/// Restore an absent panel at a captured location. Missing anchors are reported without
/// changes, allowing the app to choose an explicit default. Unrelated tabs retain selection.
/// changes, allowing the app to choose an explicit default; a floating panel whose
/// window is gone floats at its last rect instead. Unrelated tabs retain selection.
pub fn restore(&mut self, panel: P, location: &Location<P>) -> Result<(), DockError> {
self.validate()?;
if self.contains(&panel) {
@@ -604,10 +609,8 @@ impl<P: Clone + Eq + Hash> Layout<P> {
if !valid_rect(rect) {
return Err(DockError::InvalidGeometry);
}
if let Some(anchor) = &location.anchor {
if !proposed.floating.iter().any(|g| g.contains(anchor)) {
return Err(DockError::MissingTarget);
}
// A window that closed as a whole reopens at its last rect.
if let Some(anchor) = location.anchor.as_ref().filter(|anchor| proposed.floating.iter().any(|g| g.contains(anchor))) {
if let Placement::Group { .. } = location.placement {
proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?;
} else {
@@ -970,7 +973,9 @@ impl<P: Clone + Eq + Hash> Layout<P> {
fn insert(&mut self, entry: StackEntry<P>, anchor: &P, placement: Placement<P>) -> Result<(), DockError> {
let place = self.place(anchor).ok_or(DockError::MissingTarget)?;
if matches!(place, Place::Dock { .. } | Place::Floating { .. }) || matches!(placement, Placement::Group { .. } | Placement::Column { .. }) {
if matches!(place, Place::Dock { .. } | Place::Floating { .. })
|| matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_))
{
let group = Group::new(vec![entry.panel], 0);
return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement });
}
+23
View File
@@ -470,3 +470,26 @@ mod serde_tests {
}
}
}
#[test]
fn edge_placement_splits_the_part_between_the_docks_or_the_root() {
// Dock layout: a floating window lands under the frames, beside the end dock.
let mut layout = docked();
layout.apply(Action::MoveWindow { panel: 6, to: Target::At { anchor: 1, placement: Placement::Edge(Zone::Bottom) } }).unwrap();
let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("{layout:?}") };
assert!(matches!(&**second, Node::Dock { .. }));
let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(size), second: bottom, .. } = &**first else { panic!("{first:?}") };
assert_eq!(*size, 0.75);
assert_eq!(**bottom, tabs(&[6, 7, 8], 1));
assert!(layout.floating.is_empty());
// Frame layout: one panel takes the whole left edge.
let mut frames = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), tabs(&[2, 3], 0))), floating: Vec::new() };
frames.apply(Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }).unwrap();
let Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(size), first, .. }) = &frames.root else { panic!() };
assert_eq!((*size, &**first), (0.25, &tabs(&[3], 0)));
let before = frames.clone();
assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement));
assert_eq!(frames, before);
}
+9 -12
View File
@@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p
use super::{GroupKind, Renderer};
use crate::docking::{
Action, Column, Group, GroupDrag, GroupGesture, Node, Side, Target,
Action, Column, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target,
targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line},
};
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
@@ -132,7 +132,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if columns.is_empty() {
let (band, line) = column_edge(outer, inward, rect.y_range());
let band = band.union(Rect::from_x_y_ranges(
outer.min(outer + inward * self.style.edge_band)..=outer.max(outer + inward * self.style.edge_band),
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 });
@@ -418,16 +418,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
})
}
/// New docks are offered only where the dock profile is in use: in a layout with a dock.
fn has_dock(&self) -> bool {
self.layout.dock_path(Side::Start).is_some() || self.layout.dock_path(Side::End).is_some()
}
/// Dock targets, with the new-dock bands along the docked area's left and right edges that
/// have no dock yet.
fn dock_candidates(&self) -> Vec<DockDrop<P>> {
let mut candidates = self.dock_targets.clone();
if !self.has_dock() || !usable(self.workspace) {
if self.style.profile != Profile::Dock || !usable(self.workspace) {
return candidates;
}
let rtl = self.style.right_to_left;
@@ -438,8 +433,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
continue;
}
let (x, inward) = match edge {
Edge::Left => (self.workspace.left(), self.style.edge_band),
Edge::Right => (self.workspace.right(), -self.style.edge_band),
Edge::Left => (self.workspace.left(), self.style.new_dock_band),
Edge::Right => (self.workspace.right(), -self.style.new_dock_band),
};
let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys);
let line = vertical_line(x + inward.signum() * 2.0, ys);
@@ -496,10 +491,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
/// The move a group gesture makes: the column for a column bar, else the group.
/// The move a group gesture makes: the column for a column bar, every group for a window's
/// title bar, else the group.
pub(super) fn group_action<P: Clone>(drag: &GroupDrag<P>, to: Target<P>) -> Action<P> {
match drag.gesture {
GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to },
GroupGesture::Window | GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to },
GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to },
GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to },
}
}
+288 -147
View File
@@ -1,176 +1,317 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2};
use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2};
use super::{GroupKind, Renderer, clamp_float};
use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize};
use super::{GroupKind, Renderer, clamp_float, node_limits};
use crate::docking::chrome::{ChromeStyle, FloatingFrame, TitleBar};
use crate::docking::{Action, DropProposal, Floating, Group, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target};
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
/// Floating window resize handles: id suffix, hit rect and the edges they move.
fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] {
[
("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)),
(
"resize-left",
Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)),
vec2(-1.0, 0.0),
),
(
"resize-right",
Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)),
vec2(1.0, 0.0),
),
(
"resize-bottom",
Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())),
vec2(0.0, 1.0),
),
(
"resize-bottom-left",
Rect::from_min_max(
pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())),
pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()),
),
vec2(-1.0, 1.0),
),
]
pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES;
/// Height of the bar between two stacked groups of a floating window.
const GAP: f32 = 3.0;
/// A floating stack as drawn, for its band and seam targets.
pub(in crate::docking) struct StackHit<P> {
pub(super) window: Rect,
/// Each group's members, rect and tab strip, top to bottom.
pub(super) groups: Vec<(Vec<P>, Rect, Rect)>,
/// The upper group's index and the band of each seam.
pub(super) seams: Vec<(usize, Rect)>,
}
/// Names of every floating window resize handle, for focus classification.
pub(in crate::docking) const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"];
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn floating(&mut self, ctx: &Context) {
pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating<P>]) {
let screen = ctx.content_rect();
if !crate::docking::targets::usable(screen) {
return;
}
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
let mut windows: Vec<_> = self.layout.floating.iter().collect();
let mut windows: Vec<_> = windows.iter().collect();
windows.sort_by_key(|window| {
let id = window.panels().next().map(|panel| self.id.with(("floating", panel)));
layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX)
});
#[cfg(feature = "native-windows")]
let windows = self.native_windows(ctx, windows);
for window in windows {
let Some(first) = window.panels().next() else { continue };
let [x, y, width, height] = window.rect;
let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 });
let mut size = vec2(width, height).max(limits.min).min(limits.max);
if window.groups.iter().all(|group| group.minimized) {
size.y = size.y.min(window.groups.len() as f32 * self.style.tab_height);
}
let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen);
let id = self.id.with(("floating", first));
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first)))));
self.output.focused = self.activation.clone();
}
egui::Area::new(id).fade_in(false).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| {
ui.set_min_size(rect.size());
let rect = Rect::from_min_size(ui.min_rect().min, rect.size());
ui.allocate_rect(rect, Sense::hover());
// Opaque even when the host's panel colours are transparent.
ui.painter().rect_filled(rect, 0.0, self.style.floating_background);
self.stacked_groups(ui, &window.groups, rect, GroupKind::Floating { window: rect });
ui.painter().rect_stroke(rect, 0.0, self.style.floating_stroke, egui::StrokeKind::Outside);
self.resize_handles(ui, window, id, rect, limits, screen);
});
self.floating_window(ctx, window, screen);
}
}
/// Anchored resize from the side edges, the bottom edge and both bottom corners: the
/// opposite edges stay where they are.
fn resize_handles(&mut self, ui: &mut Ui, window: &Floating<P>, id: Id, rect: Rect, limits: PanelLimits, screen: Rect) {
/// Size limits of a floating window: its title bar, each group's strip and the bodies of
/// the groups that are not minimized.
pub(super) fn window_limits(&self, window: &Floating<P>) -> PanelLimits {
let mut limits = PanelLimits { min: vec2(0.0, self.style.title_height), max: vec2(1_000_000.0, self.style.title_height) };
for (index, group) in window.groups.iter().enumerate() {
let policy = node_limits(&Node::Tabs { panels: group.panels.clone(), active: 0 }, self.style, &*self.content);
let gap = if index > 0 { GAP } else { 0.0 };
limits.min.x = limits.min.x.max(policy.min.x);
limits.max.x = limits.max.x.min(policy.max.x);
let (min, max) = if group.minimized { (self.style.tab_height, self.style.tab_height) } else { (policy.min.y, policy.max.y) };
limits.min.y += min + gap;
limits.max.y += max + gap;
}
let min = limits.min.min(vec2(1_000_000.0, 1_000_000.0));
PanelLimits { min, max: limits.max.max(min).min(vec2(1_000_000.0, 1_000_000.0)) }
}
fn floating_window(&mut self, ctx: &Context, window: &Floating<P>, screen: Rect) {
let Some(first) = window.panels().next() else { return };
for (name, handle, direction) in handles(rect) {
if !crate::docking::targets::usable(handle) {
continue;
let [x, y, width, height] = window.rect;
let limits = self.window_limits(window);
let size = vec2(width, height).max(limits.min).min(limits.max);
let rect = Rect::from_min_size(pos2(x, y), size);
let id = self.id.with(("floating", first));
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
ctx.move_to_top(FloatingFrame::layer(id));
ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first)))));
self.output.focused = self.activation.clone();
}
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, rect| self.window_contents(ui, window, rect, &style),
);
for handle in &response.handles {
self.register_interaction(ctx, handle.id);
}
let down = ctx.input(|i| i.pointer.primary_down());
ctx.data_mut(|data| {
let key = self.id.with("pointer-resize");
if let Some(started) = response.resize_started {
data.insert_temp(key, started);
} else if !down && data.get_temp::<Id>(key).is_some_and(|id| response.handles.iter().any(|h| h.id == id)) {
data.remove::<Id>(key);
}
let cursor = if direction.x == 0.0 {
egui::CursorIcon::ResizeVertical
} else if direction.y == 0.0 {
egui::CursorIcon::ResizeHorizontal
} else if direction.x == direction.y {
egui::CursorIcon::ResizeNwSe
} else {
egui::CursorIcon::ResizeNeSw
});
if let Some(resized) = response.resized {
self.output
.actions
.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] });
}
}
/// The title bar, then the stacked groups below it.
pub(super) fn window_contents(&mut self, ui: &mut Ui, window: &Floating<P>, rect: Rect, style: &ChromeStyle) {
let title = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.title_height).min(rect.bottom())));
self.title_bar(ui, window, title, rect, style);
let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max);
self.floating_stack(ui, &window.groups, body, rect);
}
/// Drag moves the whole window live, double-click minimizes or expands every group, ×
/// closes every panel in it.
fn title_bar(&mut self, ui: &mut Ui, window: &Floating<P>, title: Rect, rect: Rect, style: &ChromeStyle) {
let Some(first) = window.panels().next() else { return };
let movable = window.panels().all(|p| self.permissions(p).movable);
let closable = window.panels().all(|p| self.permissions(p).closable);
let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone());
let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable);
if closable {
bar = bar.close(&close_label);
}
let response = bar.show(ui, title, style);
self.register_interaction(ui.ctx(), response.bar.id);
if movable && response.bar.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(title.center());
let drag = GroupDrag {
panel: first.clone(),
original: [rect.left(), rect.top(), rect.width(), rect.height()],
gesture: GroupGesture::Window,
members: window.panels().cloned().collect(),
origin,
source: rect,
};
let response = ui.interact(handle, id.with(name), Sense::drag()).on_hover_cursor(cursor);
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label));
let anchor_id = response.id.with("pointer-anchor");
if response.drag_started() {
response.request_focus();
if let Some(origin) = ui.input(|i| i.pointer.press_origin())
&& let Some(anchor) = AnchoredResize::new(rect, origin, direction)
{
ui.ctx().data_mut(|data| {
if let Some(previous) = data.get_temp::<Id>(self.id.with("pointer-resize")) {
data.remove::<AnchoredResize>(previous.with("pointer-anchor"));
}
data.insert_temp(self.id.with("pointer-resize"), response.id);
data.insert_temp(anchor_id, anchor);
});
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
}
if movable
&& response.bar.dragged()
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")))
&& drag.gesture == GroupGesture::Window
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
{
// From the press origin, so fractional pointer steps do not accumulate rounding.
let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect());
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
}
if response.bar.double_clicked() {
let minimized = !window.groups.iter().all(|g| g.minimized);
for group in window.groups.iter().filter(|g| g.minimized != minimized) {
if let Some(panel) = group.active_panel() {
self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized });
}
}
let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused);
let mut delta = Vec2::ZERO;
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(
response.id,
egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() },
)
});
ui.input_mut(|input| {
if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) {
delta.x -= 8.0;
}
if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) {
delta.x += 8.0;
}
if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) {
delta.y -= 8.0;
}
if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) {
delta.y += 8.0;
}
});
}
let pointer_resize = response.dragged() || response.drag_stopped();
let limits = PanelLimits { min: limits.min.max(vec2(40.0, self.style.tab_height + 20.0)).min(limits.max), ..limits };
let next = if pointer_resize {
ui.ctx()
.data(|data| data.get_temp::<AnchoredResize>(anchor_id))
.and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, screen, limits)))
} else if delta != Vec2::ZERO {
AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, screen, limits))
} else {
None
};
let permitted = response.enabled() && window.panels().all(|panel| self.permissions(panel).movable);
if permitted
&& !cancelled
&& let Some(resized) = next.filter(|next| *next != rect)
{
self.output
.actions
.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] });
}
if response.drag_stopped() {
response.surrender_focus();
}
if cancelled || !permitted || !ui.input(|input| input.pointer.primary_down()) {
ui.ctx().data_mut(|data| {
data.remove::<AnchoredResize>(anchor_id);
if data.get_temp::<Id>(self.id.with("pointer-resize")) == Some(response.id) {
data.remove::<Id>(self.id.with("pointer-resize"));
}
});
}
if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) {
ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, self.style.accent));
}
if let Some(close) = response.close {
self.register_interaction(ui.ctx(), close.id);
if close.clicked() {
self.output.actions.push(Action::CloseWindow { panel: first.clone() });
}
}
}
/// Groups top to bottom with a bar between them. Minimized groups take their strip; the
/// others share the rest by their preferred heights, equally when none is set.
pub(super) fn floating_stack(&mut self, ui: &mut Ui, groups: &[Group<P>], body: Rect, window: Rect) {
let strip = self.style.tab_height;
let open = groups.iter().filter(|g| !g.minimized).count();
let fixed = groups.len().saturating_sub(open) as f32 * strip + groups.len().saturating_sub(1) as f32 * GAP;
let available = (body.height() - fixed).max(0.0);
let equal = if open > 0 { available / open as f32 } else { 0.0 };
let wanted = |g: &Group<P>| g.height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or(equal).max(1.0);
let total: f32 = groups.iter().filter(|g| !g.minimized).map(wanted).sum();
let scale = if total > 0.0 { available / total } else { 0.0 };
let mut hit = StackHit { window, groups: Vec::new(), seams: Vec::new() };
let mut top = body.top();
let mut rects = Vec::new();
for (index, group) in groups.iter().enumerate() {
if index > 0 {
let gap = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + GAP).min(body.bottom())));
ui.painter().rect_filled(gap, 0.0, self.style.tab_background);
hit.seams.push((index - 1, Rect::from_center_size(gap.center(), vec2(gap.width(), self.style.seam_band))));
top = gap.bottom();
}
let height = if group.minimized { strip } else { wanted(group) * scale };
let rect = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + height).min(body.bottom())));
self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Floating { window });
let strip_rect = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + strip).min(rect.bottom())));
hit.groups.push((group.panels.clone(), rect, strip_rect));
rects.push(rect);
top = rect.bottom();
}
// Dividers between two open groups resize both; registered after the strips, so their
// hit region wins at the shared edge.
for (index, pair) in groups.windows(2).enumerate() {
let ([upper, lower], Some(a), Some(b)) = (pair, rects.get(index), rects.get(index + 1)) else { continue };
if upper.minimized || lower.minimized || scale <= 0.0 {
continue;
}
let (Some(upper_panel), Some(lower_panel)) = (upper.active_panel(), lower.active_panel()) else { continue };
let divider = Rect::from_min_max(pos2(body.left(), a.bottom()), pos2(body.right(), b.top()));
let least = strip + self.style.min_pane;
let pair_size = a.height() + b.height();
let (lo, hi) = if pair_size >= 2.0 * least { (least, pair_size - least) } else { (a.height(), a.height()) };
let response = ResizeHandle::new(
self.id.with(("floating-divider", upper.panels.first())),
SplitAxis::Vertical,
divider,
&self.style.resize_label,
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
)
.value(a.height(), lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
if response.delta != 0.0 {
let next = (a.height() + response.delta).clamp(lo, hi);
self.output.actions.push(Action::ResizeGroup { panel: upper_panel.clone(), height: Some(next / scale) });
self.output.actions.push(Action::ResizeGroup { panel: lower_panel.clone(), height: Some((pair_size - next) / scale) });
}
}
self.frame.stacks.push(hit);
}
/// Targets the group targets do not cover: the bands and seams of a floating stack (P3,
/// P4), then the layout's outer edge (P8). None leaves the pointer to the group targets.
/// `skip_own` looks through the window holding `members` (a window moving live).
pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option<DropProposal<P>> {
let own = |panels: &[P]| panels.iter().any(|p| members.contains(p));
let window = self.frame.stacks.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && w.groups.iter().any(|(g, ..)| own(g))));
if let Some(window) = window {
let in_group = window.groups.iter().any(|(_, rect, _)| rect.contains(pointer)) || window.seams.iter().any(|(_, b)| b.contains(pointer));
if !in_group {
// The title bar occludes what lies behind the window.
let anchor = members.first()?.clone();
return Some(DropProposal {
anchor,
placement: Placement::Tab { before: None },
preview: Rect::NOTHING,
insertion: None,
noop: true,
});
}
return self.stack_proposal(window, members, pointer);
}
let (anchor, zone, preview) = self.edge_proposal(members, pointer)?;
Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false })
}
fn stack_proposal(&self, hit: &StackHit<P>, members: &[P], pointer: Pos2) -> Option<DropProposal<P>> {
let line = |y: f32| Rect::from_min_max(pos2(hit.window.left(), y - 1.0), pos2(hit.window.right(), y + 1.0));
let same = |panels: &[P]| panels == members;
// An anchor in the group that is not itself moving.
let anchor = |panels: &[P]| panels.iter().find(|p| !members.contains(p)).cloned();
for (upper, band) in &hit.seams {
if !band.contains(pointer) {
continue;
}
let (Some((a, rect, _)), Some((b, ..))) = (hit.groups.get(*upper), hit.groups.get(upper + 1)) else { return None };
let line = line(rect.bottom() + GAP * 0.5);
if same(a) || same(b) {
let anchor = members.first()?.clone();
return Some(DropProposal {
anchor,
placement: Placement::Group { below: true },
preview: Rect::NOTHING,
insertion: None,
noop: true,
});
}
let (anchor, below) = match anchor(a) {
Some(panel) => (panel, true),
None => (anchor(b)?, false),
};
return Some(DropProposal { anchor, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion: Some(line), noop: false });
}
let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?;
if strip.contains(pointer) || same(panels) {
return None;
}
let band = rect.height() * 0.25;
let below = if pointer.y < rect.top() + band {
false
} else if pointer.y > rect.bottom() - band {
true
} else {
return None;
};
let insertion = Some(line(if below { rect.bottom() } else { rect.top() }));
Some(DropProposal { anchor: anchor(panels)?, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion, noop: false })
}
/// What a group drag carries: the whole window rect for the only group of a floating
/// window, so floating it again keeps the window's size; else the group's rect.
pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect {
match owner.kind {
GroupKind::Floating { window }
if owner.panels.first().is_some_and(|p| self.layout.floating.iter().any(|w| w.groups.len() == 1 && w.contains(p))) =>
{
window
}
_ => owner.rect,
}
}
/// Release of a title bar drag. The window already sits where it was dragged; a dock or
/// group target docks or merges every group in it, else hosts get the release in
/// `group_drop`.
pub(super) fn drop_window(&mut self, ctx: &Context, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) {
self.output.actions.extend(action);
return;
}
let proposal = if float_only { None } else { self.group_proposal(drag, pointer) };
match proposal.filter(|p| !p.noop) {
Some(proposal) => {
let to = Target::At { anchor: proposal.anchor, placement: proposal.placement };
self.output.actions.push(super::dock::group_action(drag, to));
}
None => self.output.group_drop = Some((drag.panel.clone(), pointer)),
}
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Frame profile: Maximize Frame, the outer edge band, hidden panels and the frame menu items.
use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash};
use egui::{Context, Pos2, Rect, pos2, vec2};
use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup};
use crate::docking::{
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Permissions, Profile, Zone,
dock_model::{Place, node_at},
};
/// What the renderer draws when it is not the layout itself, and the targets that depend on it.
pub(in crate::docking) struct FrameState<P> {
/// View path to layout path of each split, when the view is not the layout.
paths: Option<HashMap<Vec<bool>, Vec<bool>>>,
/// The view node whose rect is the outer edge area; none while a frame is maximized.
edge_path: Option<Vec<bool>>,
pub(super) edge: Option<Rect>,
pub(super) maximized: Option<P>,
/// Floating stacks as drawn, back to front.
pub(super) stacks: Vec<StackHit<P>>,
}
/// The layout as shown: a maximized frame alone, and hidden panels left out.
pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
layout: &'a Layout<P>,
maximized: Option<&P>,
hide: HidePanels<'_, P>,
content: &dyn DockContent<P>,
) -> (Cow<'a, Layout<P>>, FrameState<P>) {
let protected = |p: &P| content.permissions(p) == Permissions::PROTECTED;
let hidden = |p: &P| match hide {
HidePanels::None => false,
HidePanels::All => !protected(p),
HidePanels::Except(keep) => !protected(p) && !keep(p),
};
// Only a docked panel maximizes its frame.
let maximized = maximized.and_then(|panel| match layout.place(panel)? {
Place::Node(path) => Some((panel.clone(), path.clone(), layout.root.as_ref().and_then(|root| node_at(root, &path))?.clone())),
place @ Place::Dock { .. } => {
let group = layout.group(&place)?;
Some((panel.clone(), path_of(&place), Node::Tabs { panels: group.panels.clone(), active: group.active }))
}
Place::Floating { .. } => None,
});
let mut state = FrameState { paths: None, edge_path: None, edge: None, maximized: None, stacks: Vec::new() };
if maximized.is_none() && matches!(hide, HidePanels::None) {
state.edge_path = Some(layout.edge_path());
return (Cow::Borrowed(layout), state);
}
let mut paths = HashMap::new();
let view = match maximized {
Some((panel, path, node)) => {
state.maximized = Some(panel);
Layout { root: prune(&node, path, Vec::new(), &hidden, &mut paths), floating: Vec::new() }
}
None => {
let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths));
let floating = layout
.floating
.iter()
.filter_map(|window| {
let groups: Vec<_> = window.groups.iter().filter_map(|g| prune_group(g, &hidden)).collect();
(!groups.is_empty()).then(|| Floating { groups, rect: window.rect, native: window.native })
})
.collect();
Layout { root, floating }
}
};
if state.maximized.is_none() {
state.edge_path = Some(view.edge_path());
}
state.paths = Some(paths);
(Cow::Owned(view), state)
}
fn path_of(place: &Place) -> Vec<bool> {
match place {
Place::Node(path) | Place::Dock { path, .. } => path.clone(),
Place::Floating { .. } => Vec::new(),
}
}
fn visible<P>(node: &Node<P>, hidden: &dyn Fn(&P) -> bool) -> bool {
match node {
Node::Split { first, second, .. } => visible(first, hidden) || visible(second, hidden),
Node::Tabs { panels, .. } => panels.iter().any(|p| !hidden(p)),
Node::Stack { entries } => entries.iter().any(|e| !hidden(&e.panel)),
Node::Dock { columns, .. } => columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).any(|p| !hidden(p)),
}
}
/// `node` without hidden panels; a split with one visible side becomes that side. Records the
/// layout path of every split kept.
fn prune<P: Clone + PartialEq>(
node: &Node<P>,
path: Vec<bool>,
at: Vec<bool>,
hidden: &dyn Fn(&P) -> bool,
paths: &mut HashMap<Vec<bool>, Vec<bool>>,
) -> Option<Node<P>> {
let child = |path: &[bool], side: bool| [path, &[side]].concat();
match node {
Node::Split { axis, size, first, second } => match (visible(first, hidden), visible(second, hidden)) {
(true, true) => {
let first = prune(first, child(&path, false), child(&at, false), hidden, paths)?;
let second = prune(second, child(&path, true), child(&at, true), hidden, paths)?;
paths.insert(at, path);
Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) })
}
(true, false) => prune(first, child(&path, false), at, hidden, paths),
(false, true) => prune(second, child(&path, true), at, hidden, paths),
(false, false) => None,
},
Node::Tabs { panels, active } => {
let group = prune_group(&Group::new(panels.clone(), *active), hidden)?;
Some(Node::Tabs { panels: group.panels, active: group.active })
}
Node::Stack { entries } => {
let entries: Vec<_> = entries.iter().filter(|e| !hidden(&e.panel)).cloned().collect();
(!entries.is_empty()).then_some(Node::Stack { entries })
}
Node::Dock { side, columns } => {
let columns: Vec<_> = columns
.iter()
.filter_map(|c| {
let groups: Vec<_> = c.groups.iter().filter_map(|g| prune_group(g, hidden)).collect();
(!groups.is_empty()).then(|| Column { groups, ..c.clone() })
})
.collect();
(!columns.is_empty()).then_some(Node::Dock { side: *side, columns })
}
}
}
/// A group without hidden panels; the selected panel stays selected when it is shown.
fn prune_group<P: Clone + PartialEq>(group: &Group<P>, hidden: &dyn Fn(&P) -> bool) -> Option<Group<P>> {
let selected = group.active_panel();
let panels: Vec<P> = group.panels.iter().filter(|p| !hidden(p)).cloned().collect();
let active = selected.and_then(|s| panels.iter().position(|p| p == s)).unwrap_or(0);
(!panels.is_empty()).then(|| Group { panels, active, ..group.clone() })
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) {
if self.frame.edge_path.as_deref() == Some(path) {
self.frame.edge = Some(rect);
}
}
/// The layout path of a split drawn at view path `path`.
pub(super) fn original_path(&self, path: &[bool]) -> Vec<bool> {
self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec())
}
/// The outer edge band under the pointer (P8): top and bottom in both profiles, left and
/// right in the frame profile. Returns a docked anchor that is not moving, the zone and
/// the preview of the new frame.
pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> {
let area = self.frame.edge.filter(|area| area.contains(pointer))?;
let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0);
let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)];
if self.style.profile == Profile::Frame {
edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]);
}
let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?;
if distance > band {
return None;
}
let anchor = self
.layout
.panels()
.into_iter()
.find(|p| !members.contains(p) && !matches!(self.layout.place(p), Some(Place::Floating { .. }) | None))?
.clone();
let size = area.size() * 0.25;
let preview = match zone {
Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())),
Zone::Right => Rect::from_min_max(pos2(area.right() - size.x, area.top()), area.max),
Zone::Top => Rect::from_min_size(area.min, vec2(area.width(), size.y)),
Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(area.left(), area.bottom() - size.y), area.max),
};
Some((anchor, zone, preview))
}
pub(super) fn float_label(&self) -> String {
if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() }
}
pub(super) fn float_group_label(&self) -> String {
if self.style.profile == Profile::Frame { self.style.undock_group_label.clone() } else { self.style.float_group_label.clone() }
}
pub(super) fn close_group_label(&self) -> String {
if self.style.profile == Profile::Frame { self.style.close_panel_group_label.clone() } else { self.style.close_group_label.clone() }
}
/// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9).
pub(super) fn latch_undock(&self, ctx: &Context, kind: Option<GroupKind>) {
let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd);
if self.style.profile == Profile::Frame && kind == Some(GroupKind::Frame) && command {
ctx.data_mut(|d| {
if let Some(drag) = d.get_temp::<crate::docking::Drag<P>>(self.id.with("drag")) {
d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag });
}
});
}
}
/// Frame items of the menu tail: Close Other Panels in Group and Maximize Frame (frame
/// profile), and Move Panel to New Window where native windows are available.
pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
if self.style.profile == Profile::Frame {
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
if owner.panels.len() > 1 && others && self.menu_action(ui, &self.style.close_others_label.clone()) {
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
}
if owner.kind == GroupKind::Frame {
let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m));
let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() };
if self.menu_action(ui, &label) {
let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() };
self.output.events.push(DockEvent::ToggleMaximize { panel });
}
}
}
#[cfg(feature = "native-windows")]
self.new_window_item(ui, owner, panel);
}
}
+11 -64
View File
@@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash};
use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use super::{Renderer, clamp_float, stripless};
use crate::docking::{Action, DropTarget, Group, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING};
use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING};
use crate::tabs;
/// Where a group is drawn. It decides the drop targets the group offers and its gestures.
@@ -26,28 +26,10 @@ pub(super) struct TabGroup<'a, P> {
pub(super) rect: Rect,
}
/// Width of a floating window's move grip at the left of each strip.
const GRIP_WIDTH: f32 = 12.0;
/// The top band of a strip that drags the whole group.
const BORDER_BAND: f32 = 4.0;
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// Groups stacked top to bottom. Minimized groups take their strip; the others share the
/// rest equally.
pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[Group<P>], rect: Rect, kind: GroupKind) {
let strip = self.style.tab_height;
let open = groups.iter().filter(|g| !g.minimized).count();
let fixed = groups.len().saturating_sub(open) as f32 * strip;
let share = if open > 0 { (rect.height() - fixed).max(0.0) / open as f32 } else { 0.0 };
let mut top = rect.top();
for group in groups {
let bottom = (top + if group.minimized { strip } else { share }).min(rect.bottom());
let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), bottom));
self.group(ui, &group.panels, group.active, group.minimized, group_rect, kind);
top = bottom;
}
}
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 };
let policy = self.permissions(selected);
@@ -65,13 +47,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
return;
}
let movable = panels.iter().all(|p| self.permissions(p).movable);
let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
ui.painter().rect_filled(strip, 0.0, self.style.tab_background);
if let GroupKind::Floating { window } = kind {
let grip = Rect::from_min_max(strip.min, pos2((strip.left() + GRIP_WIDTH).min(strip.right()), strip.bottom()));
self.window_grip(ui, panels, grip, window, movable);
strip.min.x = grip.right();
}
let floating = matches!(kind, GroupKind::Floating { .. });
let requested = self.content.group_header_width(panels, selected, floating);
let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 };
@@ -149,38 +126,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
}
/// The floating window's own move: the window follows the pointer, every group included.
fn window_grip(&mut self, ui: &mut Ui, panels: &[P], grip: Rect, window: Rect, movable: bool) {
let Some(first) = panels.first() else { return };
let response = ui.interact(grip, self.id.with(("move-group", Some(first))), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label));
if movable && response.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
let drag = GroupDrag {
panel: first.clone(),
original: [window.left(), window.top(), window.width(), window.height()],
gesture: GroupGesture::Window,
members: panels.to_vec(),
origin,
source: window,
};
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
}
if movable
&& response.dragged()
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")))
&& drag.gesture == GroupGesture::Window
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
{
// From the press origin, so fractional pointer steps do not accumulate rounding.
let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect());
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
}
ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border);
}
/// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band
/// (`drag-border`: drag only). Both move the whole group.
fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) {
@@ -194,7 +139,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label));
if movable && response.drag_started() {
self.start_group_drag(ui, owner.panels, owner.rect);
self.start_group_drag(ui, owner.panels, self.drag_source(owner));
}
if !bar {
return;
@@ -242,7 +187,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let closable = all(&|p| p.closable);
let movable = all(&|p| p.movable);
let floatable = movable && all(&|p| p.floatable);
if closable && self.menu_action(ui, &self.style.close_group_label.clone()) {
if closable && self.menu_action(ui, &self.close_group_label()) {
self.output.actions.push(Action::CloseGroup { panel: panel.clone() });
}
if owner.kind != GroupKind::Frame {
@@ -254,7 +199,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) {
self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true });
}
if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) {
if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) {
let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
self.output.actions.push(Action::MoveGroup {
panel: panel.clone(),
@@ -280,6 +225,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
});
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical));
}
self.frame_items(ui, owner, panel);
}
/// A stock menu button with shaped text and its logical accessibility label; true when
@@ -356,15 +302,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
{
self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized });
}
if policy.movable && response.drag_started() {
begin_drag(ui.ctx(), self.id, panel.clone(), group);
if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) {
self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind));
}
response.context_menu(|ui| {
self.content.panel_menu(ui, panel);
if policy.movable && policy.floatable {
let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text);
let logical = self.float_label();
let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text);
let response = ui.button(text);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical));
if response.clicked() {
let size = self.float_size(panel, group.size());
let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect());
+9 -7
View File
@@ -8,6 +8,11 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node
mod dock;
mod floating;
mod frame;
#[cfg(feature = "native-windows")]
mod native;
pub(in crate::docking) use frame::{FrameState, view};
pub(in crate::docking) use group::GroupKind;
@@ -29,6 +34,7 @@ pub(super) struct Renderer<'a, P> {
pub(super) dock_targets: Vec<crate::docking::targets::DockDrop<P>>,
pub(super) owned_interactions: Vec<Id>,
pub(super) output: DockOutput<P>,
pub(super) frame: FrameState<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
@@ -65,6 +71,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if !crate::docking::targets::usable(rect) {
return;
}
self.note_edge(path, rect);
match node {
Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {}
Node::Split { axis, size, first, second } => {
@@ -93,7 +100,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if response.delta != 0.0
&& let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0)
{
self.output.actions.push(Action::ResizeSplit { path: path.clone(), size });
self.output.actions.push(Action::ResizeSplit { path: self.original_path(path), size });
}
}
Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame),
@@ -263,12 +270,7 @@ pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, cont
}
pub(super) fn clamp_float(rect: Rect, screen: Rect) -> Rect {
if !crate::docking::targets::usable(screen) {
return rect;
}
let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size());
let min = rect.min.max(screen.min).min(screen.max - size);
Rect::from_min_size(min, size)
crate::docking::chrome::clamp(rect, screen)
}
pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) {
+154
View File
@@ -0,0 +1,154 @@
//! Floating windows in their own OS windows (feature `native-windows`). A window with a native
//! rect is drawn with `show_viewport_immediate` where the backend has viewports; where egui
//! embeds viewports (wasm, tests) it stays in-window at its in-window rect. Tabs and groups do
//! not drag between OS windows.
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Pos2, Rect, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, vec2};
use super::{Renderer, clamp_float, group::TabGroup};
use crate::docking::{Action, Drag, Floating, GroupDrag};
/// Smallest and largest native window, and the farthest it may sit from the desktop origin.
const MIN_SIZE: [f32; 2] = [160.0, 100.0];
const MAX_SIZE: f32 = 16_384.0;
const MAX_POSITION: f32 = 100_000.0;
/// A rect safe to hand to the windowing system: finite, its size and position bounded.
/// Hostile workspace files and bad monitor reports included.
pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] {
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
let [x, y, w, h] = rect;
[
finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE),
finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE),
]
}
/// The desktop rect a native viewport reports: outer position, inner size. None when the
/// backend reports nothing usable.
fn placed(outer: Option<Rect>, inner: Option<Rect>) -> Option<[f32; 4]> {
let (outer, inner) = (outer?, inner?);
(outer.min.is_finite() && inner.size().is_finite()).then(|| sanitized([outer.min.x, outer.min.y, inner.width(), inner.height()]))
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// Show the native windows and return the windows to draw in-window.
pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating<P>>) -> Vec<&'w Floating<P>> {
let native = !ctx.embed_viewports();
let mut shown = Vec::new();
let mut in_window = Vec::new();
for window in windows {
match window.native {
Some(rect) if native && window.panels().all(|p| self.content.allow_native(p)) => {
if let Some(id) = self.native_window(ctx, window, rect) {
shown.push(id);
}
}
_ => in_window.push(window),
}
}
// Forget where windows that are no longer native were told to open.
let key = self.id.with("native-shown");
let previous = ctx.data_mut(|d| d.remove_temp::<Vec<egui::Id>>(key)).unwrap_or_default();
ctx.data_mut(|d| {
for id in previous.iter().filter(|id| !shown.contains(id)) {
d.remove::<[f32; 4]>(*id);
}
d.insert_temp(key, shown);
});
in_window
}
/// One floating window in its own viewport. Returns the key of its spawn rect.
fn native_window(&mut self, ctx: &Context, window: &Floating<P>, rect: [f32; 4]) -> Option<egui::Id> {
let first = window.panels().next()?.clone();
let viewport = ViewportId::from_hash_of((self.id, "native", &first));
// The window opens at its saved rect; after that the window system owns its position
// and size, which are read back below.
let spawn_key = self.id.with(("native-spawn", &first));
let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_key, || sanitized(rect)));
let title = window.groups.first().and_then(|g| g.active_panel()).map(|p| self.label_of(p)).unwrap_or_default();
let builder = ViewportBuilder::default()
.with_title(title)
.with_position([spawn[0], spawn[1]])
.with_inner_size([spawn[2], spawn[3]])
.with_min_inner_size(MIN_SIZE);
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus);
self.output.focused = self.activation.clone();
}
let (targets, stacks) = (self.targets.len(), self.frame.stacks.len());
let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| {
let rect = ui.max_rect();
ui.painter().rect_filled(rect, 0.0, self.style.floating_background);
self.floating_stack(ui, &window.groups, rect, rect);
// An embedded fallback reads the main window's input: its close request and place
// are not this window's.
if matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) {
ui.input(|i| (i.viewport().close_requested(), placed(i.viewport().outer_rect, i.viewport().inner_rect)))
} else {
(false, None)
}
});
// Targets in another OS window are in its coordinates, and no drag crosses windows.
self.targets.truncate(targets);
self.frame.stacks.truncate(stacks);
ctx.data_mut(|d| {
if d.get_temp::<Drag<P>>(self.id.with("drag")).is_some_and(|drag| window.contains(&drag.panel)) {
d.remove::<Drag<P>>(self.id.with("drag"));
}
if d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")).is_some_and(|drag| window.contains(&drag.panel)) {
d.remove::<GroupDrag<P>>(self.id.with("group-drag"));
}
});
if closed {
ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_key));
self.output.actions.push(Action::SetNative { panel: first, rect: None });
return None;
}
if let Some(placed) = placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) {
self.output.actions.push(Action::SetNative { panel: first, rect: Some(placed) });
}
Some(spawn_key)
}
/// Move Panel to New Window: a floating panel's window, or the panel floated on its own.
pub(super) fn new_window_item(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
let ctx = ui.ctx().clone();
if ctx.embed_viewports() {
return;
}
let window = self.layout.floating.iter().find(|w| w.contains(panel));
if window.is_some_and(|w| w.native.is_some()) {
return;
}
let movers: Vec<P> = window.map_or_else(|| vec![panel.clone()], |w| w.panels().cloned().collect());
let allowed = |p: &P| self.content.allow_native(p) && self.permissions(p).floatable && self.permissions(p).movable;
if !movers.iter().all(allowed) {
return;
}
let in_window = match window {
Some(window) => window.rect,
None => {
let size = self.float_size(panel, owner.rect.size());
let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ctx.content_rect());
[rect.left(), rect.top(), rect.width(), rect.height()]
}
};
if !self.menu_action(ui, &self.style.new_window_label.clone()) {
return;
}
// Over the panel's place in the app window: its content's position on the desktop.
let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO);
let [x, y, w, h] = in_window;
if window.is_none() {
self.output.actions.push(Action::Float { panel: panel.clone(), rect: in_window });
}
let native = sanitized([origin.x + x, origin.y + y, w, h]);
self.output.actions.push(Action::SetNative { panel: panel.clone(), rect: Some(native) });
}
}
+38 -18
View File
@@ -58,22 +58,31 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return };
match drag.gesture {
GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)),
GroupGesture::Window => self.drop_window(ctx, &drag, pointer, float_only),
GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only),
}
} else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) {
self.output.group_drag = Some(drag.panel.clone());
if drag.gesture != GroupGesture::Window {
self.paint_group_drag(ctx, &drag, pointer, float_only);
}
self.paint_group_drag(ctx, &drag, pointer, float_only);
}
}
/// The valid target under the pointer for a whole group. A target holding the group
/// itself is a no-op proposal; a target the model would reject is no target.
fn group_proposal(&self, drag: &GroupDrag<P>, pointer: Pos2) -> Option<DropProposal<P>> {
let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?;
if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) {
/// itself is a no-op proposal; a target the model would reject is no target. A window
/// moving live looks through itself and does not split frames or join a body's centre.
pub(super) fn group_proposal(&self, drag: &GroupDrag<P>, pointer: Pos2) -> Option<DropProposal<P>> {
let window = drag.gesture == GroupGesture::Window;
let valid = |proposal: DropProposal<P>| {
let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() };
(proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal)
};
if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) {
return valid(proposal);
}
let own = |t: &crate::docking::DropTarget<P>| t.tabs.iter().any(|(p, _)| drag.members.contains(p));
let target =
self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?;
if own(target) {
return Some(DropProposal {
anchor: drag.panel.clone(),
placement: Placement::Tab { before: None },
@@ -83,8 +92,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
});
}
let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?;
let action = group_action(drag, Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() });
self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal)
if window && matches!(proposal.placement, Placement::Split(_)) {
return None;
}
valid(proposal)
}
/// Release of a group drag: join or split at the target, or float at the ghost with the
@@ -117,11 +128,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
/// Ghost outline at the pointer and one destination preview.
/// Ghost outline at the pointer (a window moving live is its own ghost) and one
/// destination preview.
fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
let painter = self.preview_painter(ctx);
let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect());
painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside);
if drag.gesture != GroupGesture::Window {
let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect());
painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside);
}
if float_only {
return;
}
@@ -132,9 +146,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
return;
}
if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) {
let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center);
let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center);
let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT };
painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside);
if crate::docking::targets::usable(proposal.preview) {
painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside);
}
if let Some(marker) = proposal.insertion {
painter.rect_filled(marker, 0.0, self.style.accent);
}
@@ -166,7 +182,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
return;
}
let float_only = float_only(ctx);
let float_only = float_only(ctx) || drag.undock;
let floatable = self.permissions(&drag.panel).floatable
&& (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel)));
let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer));
@@ -189,7 +205,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
return;
}
let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) };
let proposal = if float_only {
None
} else {
self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| resolve_drop(&drag.panel, pointer, &self.targets))
};
if released {
cancel_drag::<P>(ctx, self.id);
if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) {
@@ -218,7 +238,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
// Reordering inside the source strip shows only the insertion line.
let same_group = proposal.insertion.is_some()
&& self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel));
if !same_group && !proposal.noop {
if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) {
painter.rect(
proposal.preview.shrink(1.0),
0.0,
+120 -20
View File
@@ -45,12 +45,20 @@ pub struct DockStyle {
/// Painted under every floating window, so it stays opaque when the panel colours are not.
pub floating_background: Color32,
pub floating_stroke: Stroke,
/// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with
/// Maximize, Ctrl/Cmd-drag undock, splits on all four outer edges and frame menu labels.
pub profile: Profile,
/// Height of a floating window's title bar.
pub title_height: f32,
/// Width of the band along the layout's outer edge that splits at the root.
pub edge_band: f32,
/// [D] Height of the bar above each dock column. It drags the column and holds its »/« button.
pub column_bar_height: f32,
/// [D] Height of the band around the seam between two stacked groups.
/// [D] Height of the band around the seam between two stacked groups, in docks and
/// floating windows.
pub seam_band: f32,
/// [D] Width of the band along the window's left and right edges that makes a new dock.
pub edge_band: f32,
/// [D] Width of the band along the docked area's left and right edges that makes a new dock.
pub new_dock_band: f32,
/// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide
/// layout direction, so the host sets this from its locale. Frame splits keep their sides.
pub right_to_left: bool,
@@ -58,6 +66,25 @@ pub struct DockStyle {
pub move_column_label: String,
pub collapse_to_icons_label: String,
pub expand_panels_label: String,
pub move_window_label: String,
pub close_window_label: String,
/// Frame profile menu labels.
pub undock_label: String,
pub undock_group_label: String,
pub close_panel_group_label: String,
pub close_others_label: String,
pub maximize_label: String,
pub restore_frame_label: String,
/// Shown where native windows are available (feature `native-windows`).
pub new_window_label: String,
}
/// Which arrangement a [`DockStyle`] renders and labels.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Profile {
#[default]
Dock,
Frame,
}
impl DockStyle {
@@ -90,21 +117,45 @@ impl DockStyle {
drag_bar_width: 40.0,
floating_background: visuals.window_fill,
floating_stroke: visuals.window_stroke,
profile: Profile::Dock,
title_height: super::chrome::TITLE_HEIGHT,
edge_band: 8.0,
column_bar_height: 14.0,
seam_band: 8.0,
edge_band: 12.0,
new_dock_band: 12.0,
right_to_left: false,
move_column_label: "Move panel column".into(),
collapse_to_icons_label: "Collapse to Icons".into(),
expand_panels_label: "Expand Panels".into(),
move_window_label: "Move panel window".into(),
close_window_label: "Close panel window".into(),
undock_label: "Undock Panel".into(),
undock_group_label: "Undock Panel Group".into(),
close_panel_group_label: "Close Panel Group".into(),
close_others_label: "Close Other Panels in Group".into(),
maximize_label: "Maximize Frame".into(),
restore_frame_label: "Restore Frame Size".into(),
new_window_label: "Move Panel to New Window".into(),
}
}
pub(super) fn valid(&self) -> bool {
[self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width]
.iter()
.chain(&[self.column_bar_height, self.seam_band, self.edge_band])
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
[
self.tab_height,
self.gap,
self.min_pane,
self.border.width,
self.font.size,
self.drag_bar_width,
self.floating_stroke.width,
self.title_height,
self.edge_band,
self.column_bar_height,
self.seam_band,
self.new_dock_band,
]
.iter()
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
&& self.tab_height > 0.0
&& self.font.size > 0.0
}
@@ -119,14 +170,41 @@ pub struct DockOutput<P> {
pub group_drag: Option<P>,
/// Whole-group release for host-owned atomic docking policy.
pub group_drop: Option<(P, Pos2)>,
/// Requests that are not layout actions.
pub events: Vec<DockEvent<P>>,
}
impl<P> Default for DockOutput<P> {
fn default() -> Self {
Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None }
Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new() }
}
}
/// A request for host state outside the layout.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DockEvent<P> {
/// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one
/// passed to [`DockArea::maximized`].
ToggleMaximize { panel: P },
}
/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay.
pub enum HidePanels<'a, P> {
None,
All,
/// Every panel except those for which the function returns true.
Except(&'a dyn Fn(&P) -> bool),
}
impl<P> Clone for HidePanels<'_, P> {
fn clone(&self) -> Self {
*self
}
}
impl<P> Copy for HidePanels<'_, P> {}
/// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing
/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -160,6 +238,8 @@ pub(super) struct Drag<P> {
pub(super) origin: Pos2,
pub(super) source: Rect,
pub(super) grab: Vec2,
/// Ctrl/Cmd held at the press of a frame-profile tab drag: the drop always floats.
pub(super) undock: bool,
}
/// What a group gesture moves.
@@ -194,7 +274,7 @@ pub fn begin_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id,
return false;
}
let grab = (origin - source_rect.min).clamp(Vec2::ZERO, source_rect.size());
ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab }));
ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab, undock: false }));
true
}
@@ -362,15 +442,31 @@ impl<P, C: DockContent<P> + ?Sized> DockContent<P> for Overrides<'_, P, C> {
/// Optional renderer for applications without an existing docking surface. Apps with custom
/// chrome can instead use the model, target resolver and resize/tab primitives independently.
pub struct DockArea<P = ()> {
pub struct DockArea<'a, P = ()> {
id: Id,
activation: Option<P>,
blockers: Vec<Rect>,
maximized: Option<P>,
hide: HidePanels<'a, P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
pub fn new(id: Id) -> Self {
Self { id, activation: None, blockers: Vec::new() }
Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None }
}
/// Maximize Frame: the docked frame holding `panel` fills the area; other frames and
/// floating windows do not render or take input. A floating or absent panel is ignored.
pub fn maximized(mut self, panel: Option<P>) -> Self {
self.maximized = panel;
self
}
/// Hide panels (Tab, Shift+Tab); their space goes to the panels that stay. Protected
/// panels always stay. The layout is unchanged.
pub fn hide_panels(mut self, hide: HidePanels<'a, P>) -> Self {
self.hide = hide;
self
}
/// Whether keyboard focus belongs to a rendered docking control in this layout.
@@ -389,10 +485,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
};
for floating in &layout.floating {
if floating.groups.iter().any(|g| group(&g.panels))
|| floating
.panels()
.next()
.is_some_and(|first| RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))))
|| floating.panels().next().is_some_and(|first| {
RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name)))
|| matches(self.id.with(("move-group", Some(first))).with("close"))
})
|| floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first()))))
{
return true;
}
@@ -452,7 +549,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
/// Render a validated snapshot and return requested actions for the app's command handler.
/// No action is applied here. Panel contents get a global stable egui ID across moves.
/// Floating groups are egui areas inside this viewport; this does not create native windows.
/// Floating windows are egui areas inside this viewport; with feature `native-windows`, a
/// window with a native rect gets its own OS window where the backend has viewports.
pub fn show(
self,
ui: &mut Ui,
@@ -508,6 +606,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
cancel_drag::<P>(ui.ctx(), self.id);
return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() };
}
let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content);
let mut renderer = Renderer {
id: self.id,
style,
@@ -520,12 +619,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
dock_targets: Vec::new(),
owned_interactions: Vec::new(),
output: DockOutput::default(),
frame,
};
if let Some(root) = &layout.root {
if let Some(root) = &view.root {
renderer.node(ui, root, rect, &mut Vec::new());
ui.allocate_rect(rect, Sense::hover());
}
renderer.floating(ui.ctx());
renderer.floating(ui.ctx(), &view.floating);
renderer.finish_group_drag(ui.ctx());
renderer.finish_drag(ui.ctx());
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
+3 -5
View File
@@ -904,7 +904,7 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() {
assert!(out.actions.is_empty());
assert!(content.headers >= 3 && content.bodies >= 3);
assert!(content.labels.iter().any(|s| s == "Logical 5"));
let at = pos2(130.0, 82.0);
let at = pos2(130.0, 96.0);
custom_frame(
&ctx,
&layout,
@@ -1249,9 +1249,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() {
h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))),
"custom action must paint: {logical}"
);
// This sample also has a floating-group grip labeled Move to group.
let expected = if logical == "Move to group" { 2 } else { 1 };
assert_eq!(h.query_all_by_label(logical).count(), expected, "popup action must retain its own logical accessibility: {logical}");
assert_eq!(h.query_all_by_label(logical).count(), 1, "popup action must retain its own logical accessibility: {logical}");
assert_eq!(
h.query_all_by_label(&format!("Action visual: {logical}")).count(),
0,
@@ -1368,7 +1366,7 @@ fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() {
}
ctx.memory_mut(|memory| memory.request_focus(id));
frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 26.0]);
assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 40.0]);
}
#[test]