docking: iconic columns with flyouts, hit rects and a test driver

An iconic column draws one icon per panel, a grip above each group and a
separator between groups (M3). Widened past `DockStyle::iconic_label_width`
it shows panel names (M4). Clicking an icon opens its group as a flyout on
the canvas side; another icon switches, the same icon closes, and with
Auto-Collapse Iconic Panels (default on) Escape or a press outside closes
it (M5). Dragging a flyout's strip floats its group (M6), an icon drags its
panel (S7) and the grip its group (S8). A drop into an iconic column makes
a new icon group, previewed by the column outline and an insertion line
(P9). `raise_panel` on an iconic panel opens its flyout (U3), and the
column menu has the Auto-Collapse toggle as `DockEvent::ToggleAutoCollapse`.

Iconic widths move to `DockStyle` (`icon_size`, `iconic_width`,
`iconic_max_width`). `DockArea::hit_rects` names every gesture source the
area drew last frame, and `docking::testing::DockDriver` (feature
`testing`) drives an area with real egui input for application tests.

Behaviour follows vectorcraft crates/ui-egui/src/dock.rs (icon column,
flyout placement, names when widened) and photocraft dock.rs
(auto-collapse).
This commit is contained in:
aiongg committed 2026-10-19 05:40:26 +00:00
1 parent 315fde3ed8
commit 0c140eb65b
10 files changed
+1073 -16

No files matched your search

+2
View File
@@ -18,6 +18,8 @@ serde = { version = "1", features = ["derive"], optional = true }
serde = ["dep:serde"]
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
native-windows = []
# `docking::testing`: a headless driver for application docking tests.
testing = []
[dev-dependencies]
egui = { workspace = true, features = ["default_fonts"] }
+124
View File
@@ -0,0 +1,124 @@
//! Where each docking gesture starts, for control protocols and tests.
use std::{collections::HashMap, fmt::Debug, hash::Hash};
use egui::{Context, Id, Rect};
use super::{DockArea, Layout, Node, render::RESIZE_HANDLES};
/// A rendered docking control that starts a gesture. Groups, columns and windows are named by
/// their first panel.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Hit<P> {
/// A tab: click activates, drag moves the panel (S1).
Tab(P),
/// A group's drag bar: click opens the group menu, drag moves the group (S2).
DragBar(P),
/// The top border of a group's strip: drag moves the group (S3).
Border(P),
/// The "…" button of a group whose tabs overflow; named by the active panel.
Overflow(P),
/// [D] A column bar: drag moves the column (S6).
ColumnBar(P),
/// [D] The »/« button of a column bar (M2).
Collapse(P),
/// An accordion entry's −/+ button.
StackToggle(P),
/// A floating window's title bar (S5).
Title(P),
/// A floating window's × button.
Close(P),
/// [D] A panel's icon in an iconic column (S7, M5).
Icon(P),
/// [D] The grip above an icon group (S8).
IconGrip(P),
/// A floating window's resize handle.
Resize(P, ResizeEdge),
Divider(Divider<P>),
}
/// The edge or corner a floating window's resize handle moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ResizeEdge {
Left,
Right,
Bottom,
BottomLeft,
BottomRight,
}
/// A divider that resizes what is on either side of it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Divider<P> {
/// A split, by its path from the root (see [`super::Action::ResizeSplit`]).
Split(Vec<bool>),
/// Below the accordion entry of this panel.
Stack(P),
/// [D] The inner edge of the column holding this panel first.
Column(P),
/// Below the dock or floating group holding this panel first.
Group(P),
}
fn key(area: Id) -> Id {
area.with("hit-rects")
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'_, P> {
/// Every gesture source the area drew in its last frame, with its rect in points. Hosts
/// return these from a `ui.docking` control method beside the layout and `focused`; tests
/// drag from their centres.
pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit<P>, Rect)> {
ctx.data(|d| d.get_temp::<Vec<(Hit<P>, Rect)>>(key(id))).unwrap_or_default()
}
}
/// Name the controls the renderer registered this frame and store their rects.
pub(super) fn record<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>, owned: &[Id]) {
let mut names: HashMap<Id, Hit<P>> = HashMap::new();
for panel in layout.panels() {
let p = || panel.clone();
let some = Some(panel);
names.insert(area.with(("tab", panel)), Hit::Tab(p()));
names.insert(area.with(("icon", panel)), Hit::Icon(p()));
names.insert(area.with(("overflow", panel)), Hit::Overflow(p()));
names.insert(area.with(("collapse", panel)), Hit::StackToggle(p()));
names.insert(area.with(("stack-resize", panel)), Hit::Divider(Divider::Stack(p())));
names.insert(area.with(("drag-bar", some)), Hit::DragBar(p()));
names.insert(area.with(("drag-border", some)), Hit::Border(p()));
names.insert(area.with(("icon-grip", some)), Hit::IconGrip(p()));
names.insert(area.with(("column-bar", some)), Hit::ColumnBar(p()));
names.insert(area.with(("column-collapse", some)), Hit::Collapse(p()));
names.insert(area.with(("column-resize", some)), Hit::Divider(Divider::Column(p())));
names.insert(area.with(("group-resize", some)), Hit::Divider(Divider::Group(p())));
names.insert(area.with(("floating-divider", some)), Hit::Divider(Divider::Group(p())));
let title = area.with(("move-group", some));
names.insert(title, Hit::Title(p()));
names.insert(title.with("close"), Hit::Close(p()));
let window = area.with(("floating", panel));
let edges = [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft];
for (name, edge) in RESIZE_HANDLES.iter().zip(edges) {
names.insert(window.with(*name), Hit::Resize(p(), edge));
}
}
let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::<bool>::new())]).unwrap_or_default();
while let Some((node, path)) = nodes.pop() {
if let Node::Split { first, second, .. } = node {
names.insert(area.with(("split", &path)), Hit::Divider(Divider::Split(path.clone())));
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
first_path.push(false);
nodes.push((second, second_path));
nodes.push((first, first_path));
}
}
let hits: Vec<(Hit<P>, Rect)> =
owned.iter().filter_map(|id| Some((names.remove(id)?, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect();
ctx.data_mut(|d| d.insert_temp(key(area), hits));
}
+324
View File
@@ -0,0 +1,324 @@
//! [D] Iconic columns, flyouts and hit rects, driven through `testing::DockDriver`.
use super::testing::DockDriver;
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Key, Pos2, Rect, pos2, vec2};
struct Content;
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() }
}
}
type Driver = DockDriver<u32, Content>;
/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0.
fn docked(groups: &[&[u32]], iconic: bool) -> Layout<u32> {
let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0);
column.iconic = iconic;
let dock = Node::Dock { side: Side::Start, columns: vec![column] };
let canvas = Node::Tabs { panels: vec![0], active: 0 };
Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }),
floating: Vec::new(),
}
}
fn first_column(layout: &Layout<u32>) -> Column<u32> {
match &layout.root {
Some(Node::Split { first, .. }) => match first.as_ref() {
Node::Dock { columns, .. } => columns.first().cloned().unwrap(),
other => panic!("no dock: {other:?}"),
},
other => panic!("no split: {other:?}"),
}
}
fn groups(layout: &Layout<u32>) -> Vec<Vec<u32>> {
first_column(layout).groups.iter().map(|g| g.panels.clone()).collect()
}
fn hit(d: &Driver, hit: Hit<u32>) -> Rect {
d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits()))
}
fn open(d: &Driver) -> Option<u32> {
flyout::<u32>(&d.ctx, d.id)
}
fn texts(d: &Driver) -> Vec<String> {
d.shapes()
.iter()
.filter_map(|clipped| match &clipped.shape {
egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
_ => None,
})
.collect()
}
const CANVAS: Pos2 = pos2(600.0, 400.0);
#[test]
fn collapsed_column_shows_one_icon_per_panel_with_group_separators() {
let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3)));
let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3)));
assert_eq!(one.width(), 36.0, "the column takes the style's iconic width");
assert_eq!((grip1.bottom(), one.bottom()), (one.top(), two.top()), "a group's icons follow its grip and each other");
assert_eq!(grip3.top() - two.bottom(), 7.0, "groups are apart by the separator");
assert_eq!(three.top(), grip3.bottom());
assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs");
}
#[test]
fn widening_an_iconic_column_shows_labels() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
let out = d.drag(edge.center(), pos2(150.0, edge.center().y));
assert!(out.error.is_none());
let width = first_column(&d.layout).iconic_width.unwrap();
assert!((100.0..=160.0).contains(&width), "{width}");
d.settle();
let labels = texts(&d);
assert!(["Panel 1", "Panel 2", "Panel 3"].iter().all(|l| labels.iter().any(|t| t == l)), "{labels:?}");
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
d.drag(edge.center(), pos2(20.0, edge.center().y));
d.settle();
assert_eq!(first_column(&d.layout).iconic_width, Some(36.0));
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
}
#[test]
fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
let icon = hit(&d, Hit::Icon(2)).center();
let out = d.click(icon);
assert!(out.actions.contains(&Action::Activate { panel: 2 }));
d.settle();
assert_eq!(open(&d), Some(2));
let column = hit(&d, Hit::Icon(1));
let tab = hit(&d, Hit::Tab(2));
assert!(tab.left() >= column.right(), "the flyout opens on the canvas side: {tab:?}");
assert!(tab.top() <= hit(&d, Hit::IconGrip(1)).bottom(), "beside its group");
assert_eq!(first_column(&d.layout).groups.first().map(|g| g.active), Some(1));
// Another icon switches flyouts, the same icon closes.
d.click(hit(&d, Hit::Icon(3)).center());
d.settle();
assert_eq!(open(&d), Some(3));
assert!(d.hit(&Hit::Tab(2)).is_none());
d.click(hit(&d, Hit::Icon(3)).center());
d.settle();
assert_eq!(open(&d), None);
// Escape and a press outside close it.
d.click(icon);
d.settle();
d.key(Key::Escape);
d.settle();
assert_eq!(open(&d), None);
d.click(icon);
d.settle();
let inside = hit(&d, Hit::Tab(2)).center();
d.click(inside);
d.settle();
assert_eq!(open(&d), Some(2), "a press inside keeps it");
d.click(CANVAS);
d.settle();
assert_eq!(open(&d), None);
// Auto-Collapse off: it stays until an icon closes it.
d.auto_collapse = false;
d.click(icon);
d.settle();
d.key(Key::Escape);
d.click(CANVAS);
d.settle();
assert_eq!(open(&d), Some(2));
d.click(icon);
d.settle();
assert_eq!(open(&d), None);
// U3: raising an iconic panel, as the Window menu does, opens its flyout.
d.raise = Some(3);
d.settle();
assert_eq!(open(&d), Some(3));
}
#[test]
fn auto_collapse_toggle_is_a_column_menu_event() {
use egui_kittest::{Harness, kittest::Queryable};
let layout = docked(&[&[1, 2]], true);
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, events: &mut Vec<DockEvent<u32>>| {
let out = DockArea::new(egui::Id::new("menu")).show_content(ui, &layout, &DockStyle::from_ui(ui), &mut Content);
events.extend(out.events);
},
Vec::new(),
);
h.run_steps(2);
let bar = h.ctx.read_response(egui::Id::new("menu").with(("column-bar", Some(&1u32)))).unwrap().rect.center();
h.input_mut().events.extend([
egui::Event::PointerMoved(bar),
egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(egui::Event::PointerButton {
pos: bar,
button: egui::PointerButton::Secondary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(2);
h.get_by_label("Auto-Collapse Iconic Panels").click();
h.run_steps(2);
assert_eq!(h.state(), &vec![DockEvent::ToggleAutoCollapse]);
}
#[test]
fn dragging_a_flyout_strip_floats_its_group() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
d.click(hit(&d, Hit::Icon(1)).center());
d.settle();
let bar = hit(&d, Hit::DragBar(1));
let out = d.drag(bar.center(), CANVAS);
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 1, to: Target::Float { .. } }]), "{:?}", out.actions);
assert_eq!(groups(&d.layout), vec![vec![3]]);
assert_eq!(d.layout.floating.len(), 1);
assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::<Vec<_>>()), Some(vec![1, 2]));
d.settle();
assert_eq!(open(&d), None, "the flyout closes with its group gone");
// A tab dragged out of a flyout floats that panel alone.
d.click(hit(&d, Hit::Icon(3)).center());
d.settle();
let tab = hit(&d, Hit::Tab(3));
let out = d.drag(tab.center(), pos2(450.0, 200.0));
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
}
#[test]
fn icon_and_icon_group_grip_drags_move_a_panel_and_a_group() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
let before = d.layout.clone();
// Escape, focus loss and cancel_drag end an icon drag with no change.
let icon = hit(&d, Hit::Icon(2)).center();
d.hold(icon, CANVAS);
d.key(Key::Escape);
d.release(CANVAS);
d.hold(icon, CANVAS);
d.focus(false);
d.focus(true);
d.release(CANVAS);
d.hold(icon, CANVAS);
cancel_drag::<u32>(&d.ctx, d.id);
d.release(CANVAS);
assert_eq!(d.layout, before);
let out = d.drag(icon, CANVAS);
let Some(Action::Float { panel: 2, rect }) = out.actions.first() else { panic!("{:?}", out.actions) };
assert_eq!((rect[2], rect[3]), (240.0, 320.0), "a panel floated from its icon takes the column's expanded width");
assert_eq!(groups(&d.layout), vec![vec![1], vec![3]]);
let grip = hit(&d, Hit::IconGrip(3)).center();
let out = d.drag(grip, pos2(450.0, 250.0));
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions);
assert_eq!(groups(&d.layout), vec![vec![1]]);
}
#[test]
fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() {
let mut layout = docked(&[&[1], &[2]], true);
layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0]));
let mut d = DockDriver::new(layout, Content);
let bar = hit(&d, Hit::DragBar(5)).center();
let one = hit(&d, Hit::Icon(1));
let target = pos2(one.center().x, one.bottom() - 2.0);
d.hold(bar, target);
let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0));
assert!(d.outlined(column.shrink(1.0)), "the column is outlined");
let line = Rect::from_x_y_ranges(column.x_range(), (one.bottom() - 2.0)..=(one.bottom() + 2.0));
assert!(d.filled(line), "with the insertion line under group 1");
let out = d.release(target);
assert!(
out.actions.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true } } }),
"{:?}",
out.actions
);
assert_eq!(groups(&d.layout), vec![vec![1], vec![5, 6], vec![2]]);
assert!(first_column(&d.layout).iconic);
}
/// Every kind of gesture source has a rect, and dragging from it does something.
#[test]
fn hit_rects_cover_every_gesture_source() {
let mut expanded = Column::new(vec![Group::new(vec![1, 2, 3, 4, 5, 6, 7], 0), Group::new(vec![8], 0)], 150.0);
expanded.width = 150.0;
let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0);
icons.iconic = true;
let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] };
let stack = Node::Stack {
entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }],
};
let frames = Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.5),
first: Box::new(Node::Tabs { panels: vec![0], active: 0 }),
second: Box::new(stack),
};
let mut window = Floating::single(vec![12], 0, [500.0, 60.0, 220.0, 300.0]);
window.groups.push(Group::new(vec![13], 0));
let layout = Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }),
floating: vec![window],
};
let d = DockDriver::new(layout.clone(), Content);
let hits = d.hits();
let kinds = |f: &dyn Fn(&Hit<u32>) -> bool| hits.iter().filter(|(h, _)| f(h)).count();
let expected: [(&str, &dyn Fn(&Hit<u32>) -> bool); 15] = [
("tab", &|h| matches!(h, Hit::Tab(_))),
("drag bar", &|h| matches!(h, Hit::DragBar(_))),
("border", &|h| matches!(h, Hit::Border(_))),
("overflow", &|h| matches!(h, Hit::Overflow(_))),
("column bar", &|h| matches!(h, Hit::ColumnBar(_))),
("collapse", &|h| matches!(h, Hit::Collapse(_))),
("stack toggle", &|h| matches!(h, Hit::StackToggle(_))),
("title", &|h| matches!(h, Hit::Title(12))),
("close", &|h| matches!(h, Hit::Close(12))),
("icon", &|h| matches!(h, Hit::Icon(9))),
("icon grip", &|h| matches!(h, Hit::IconGrip(9))),
("resize", &|h| matches!(h, Hit::Resize(12, _))),
("split", &|h| matches!(h, Hit::Divider(Divider::Split(_)))),
("column divider", &|h| matches!(h, Hit::Divider(Divider::Column(_)))),
("group divider", &|h| matches!(h, Hit::Divider(Divider::Group(_)))),
];
for (name, kind) in expected {
assert!(kinds(kind) > 0, "{name}: {hits:?}");
}
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10)))));
assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 5);
for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
let mut d = DockDriver::new(layout.clone(), Content);
let from = rect.center();
let moved = d.hold(from, from + vec2(-30.0, 40.0));
let dragging = is_dragging::<u32>(&d.ctx, d.id);
let released = d.release(from + vec2(-30.0, 40.0));
assert!(dragging || !moved.actions.is_empty() || !released.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
}
for (hit, rect) in hits.iter().filter(|(h, _)| matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
let mut d = DockDriver::new(layout.clone(), Content);
let out = d.click(rect.center());
let opened = egui::Popup::is_any_open(&d.ctx);
assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
}
}
+7
View File
@@ -7,13 +7,18 @@
pub mod chrome;
mod dock_model;
mod hits;
mod model;
mod render;
mod resize;
mod surface;
mod targets;
#[cfg(any(test, feature = "testing"))]
pub mod testing;
mod workspace;
pub use hits::{Divider, Hit, ResizeEdge};
pub use render::{close_flyout, flyout, open_flyout};
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::{
@@ -32,6 +37,8 @@ mod floating_tests;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod iconic_tests;
#[cfg(test)]
mod model_tests;
#[cfg(test)]
mod tests;
+22 -13
View File
@@ -10,15 +10,11 @@ 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, Profile, Side, Target,
Action, Column, DockEvent, DockStyle, 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};
/// Width of an iconic column without a saved width.
const ICONIC_WIDTH: f32 = 36.0;
/// Widest an iconic column gets by dragging its inner edge.
const ICONIC_MAX: f32 = 240.0;
/// Width of the »/« button at the outer end of a column bar.
const COLLAPSE_WIDTH: f32 = 18.0;
@@ -44,8 +40,12 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side {
}
}
pub(in crate::docking) fn column_width<P>(column: &Column<P>) -> f32 {
if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH) } else { column.width }
pub(in crate::docking) fn column_width<P>(column: &Column<P>, style: &DockStyle) -> f32 {
if column.iconic {
column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width)
} else {
column.width
}
}
/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved
@@ -104,7 +104,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
};
let gap = self.style.gap;
let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width());
let width = columns.iter().map(column_width).sum::<f32>().clamp(0.0, room);
let width = columns.iter().map(|c| column_width(c, self.style)).sum::<f32>().clamp(0.0, room);
let (dock, rest) = match edge_of(side, self.style.right_to_left) {
Edge::Left => (
Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())),
@@ -141,12 +141,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if usable(rect) {
ui.painter().rect_filled(rect, 0.0, self.style.background);
}
let total: f32 = columns.iter().map(column_width).sum();
let total: f32 = columns.iter().map(|c| column_width(c, self.style)).sum();
let mut x = outer;
let mut drawn = Vec::new();
for (index, column) in columns.iter().enumerate() {
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue };
let width = column_width(column).min((rect.width() - (x - outer).abs()).max(0.0));
let width = column_width(column, self.style).min((rect.width() - (x - outer).abs()).max(0.0));
let inner = x + inward * width;
let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range());
self.column(ui, column, first, column_rect, edge);
@@ -161,8 +161,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
// Registered after the columns, so the narrow hit areas win at the edges.
for (column, first, width, inner) in drawn {
let others = total - column_width(column);
let (lo, hi) = if column.iconic { (ICONIC_WIDTH, ICONIC_MAX) } else { self.column_bounds(column) };
let others = total - column_width(column, self.style);
let (lo, hi) = if column.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(column) };
let hi = hi.min(room - others).max(lo.min(room - others).max(0.0));
let lo = lo.min(hi);
let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range());
@@ -216,6 +216,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom())));
self.column_bar(ui, column, first, bar, rect, edge);
let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max);
if column.iconic && usable(body) {
self.iconic_column(ui, column, body, edge);
}
if column.iconic || !usable(body) {
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
return;
@@ -404,6 +407,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if self.menu_action(ui, &label) {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
if self.auto_collapse_item(ui) {
self.output.events.push(DockEvent::ToggleAutoCollapse);
}
});
}
@@ -411,6 +417,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// covers `pointer`. Covered dock targets take nothing.
fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool {
self.blockers.iter().any(|r| r.contains(pointer))
|| self.iconic.covers(pointer)
|| self.layout.floating.iter().any(|window| {
let Some(first) = window.panels().next() else { return false };
!moving.is_some_and(|p| window.contains(p))
@@ -486,7 +493,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// The insertion line of a dock target.
pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) {
if usable(line) {
self.preview_painter(ctx).rect_filled(line, 1.0, self.style.accent);
let painter = self.preview_painter(ctx);
self.iconic.outline(&painter, line, self.style);
painter.rect_filled(line, 1.0, self.style.accent);
}
}
}
+328
View File
@@ -0,0 +1,328 @@
//! [D] Iconic columns: one icon per panel, groups apart, and the flyout an icon opens.
//!
//! Behaviour follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side,
//! names when the column is widened) and PhotoCraft `dock.rs` (Auto-Collapse Iconic Panels).
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use super::{GroupKind, Renderer, dock::Edge};
use crate::docking::{
Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag,
chrome::{ChromeStyle, FloatingFrame},
is_dragging,
targets::{DockDrop, DockTarget, horizontal_line, usable},
};
/// Height of the grip above each icon group.
const GRIP: f32 = 6.0;
/// Room between two icon groups, with the separator line in its middle.
const SEPARATOR: f32 = 7.0;
/// Height of a flyout, or of a panel floated from an icon, when nothing else sizes it.
const FLYOUT_HEIGHT: f32 = 320.0;
/// The panel whose icon opened the flyout of an area.
#[derive(Clone)]
struct Open<P>(P);
fn key(area: Id) -> Id {
area.with("flyout")
}
/// [D] Open the flyout of the group holding `panel`, when that group is in an iconic column.
/// The flyout shows from the next frame on. [`crate::docking::DockArea::raise_panel`] does the
/// same for a panel the Window menu opens.
pub fn open_flyout<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id, panel: P) {
ctx.data_mut(|d| d.insert_temp(key(area_id), Open(panel)));
}
/// [D] Close the area's flyout.
pub fn close_flyout<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) {
ctx.data_mut(|d| d.remove::<Open<P>>(key(area_id)));
}
/// [D] The panel whose icon opened the area's flyout, while one is open.
pub fn flyout<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) -> Option<P> {
ctx.data(|d| d.get_temp::<Open<P>>(key(area_id))).map(|open| open.0)
}
/// The flyout an iconic column asks for this frame.
struct Request<P> {
group: Group<P>,
/// The icons of the group, where the flyout's top aligns.
icons: Rect,
/// The iconic column, which a press does not close the flyout in.
column: Rect,
edge: Edge,
/// The column's expanded width.
width: f32,
}
/// Per-frame iconic state of a renderer.
pub(in crate::docking) struct Iconic<P> {
auto_collapse: bool,
/// Iconic column bodies drawn this frame, outlined by a drop into them (P9).
columns: Vec<Rect>,
/// Expanded column width of each panel drawn as an icon: a panel floated from its icon
/// takes it.
widths: Vec<(P, f32)>,
request: Option<Request<P>>,
/// The flyout drawn this frame; it occludes dock targets behind it.
rect: Option<Rect>,
}
impl<P: PartialEq> Iconic<P> {
pub(in crate::docking) fn new(auto_collapse: bool) -> Self {
Self { auto_collapse, columns: Vec::new(), widths: Vec::new(), request: None, rect: None }
}
pub(super) fn covers(&self, pointer: Pos2) -> bool {
self.rect.is_some_and(|rect| rect.contains(pointer))
}
/// Outline the iconic column an insertion line lies in.
pub(super) fn outline(&self, painter: &egui::Painter, line: Rect, style: &DockStyle) {
if let Some(column) = self.columns.iter().find(|column| column.shrink(1.0).contains(line.center())) {
painter.rect_stroke(column.shrink(1.0), 0.0, Stroke::new(2.0, style.accent), StrokeKind::Inside);
}
}
pub(super) fn float_width(&self, panel: &P) -> Option<f32> {
self.widths.iter().find(|(p, _)| p == panel).map(|(_, width)| *width)
}
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
fn row_height(&self) -> f32 {
self.style.icon_size + 10.0
}
fn icon_group_height(&self, group: &Group<P>) -> f32 {
GRIP + group.panels.len() as f32 * self.row_height()
}
/// The body of an iconic column: icon groups top to bottom, scrolling when they do not fit.
pub(super) fn iconic_column(&mut self, ui: &mut Ui, column: &Column<P>, body: Rect, edge: Edge) {
self.iconic.columns.push(body);
for panel in column.groups.iter().flat_map(|g| g.panels.iter()) {
self.iconic.widths.push((panel.clone(), column.width));
}
// Opening a panel from the Window menu opens its flyout (U3).
if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) {
open_flyout(ui.ctx(), self.id, panel.clone());
}
let total: f32 = column.groups.iter().map(|g| self.icon_group_height(g)).sum::<f32>()
+ column.groups.len().saturating_sub(1) as f32 * SEPARATOR;
if total > body.height() + 0.5 {
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return };
let mut child =
ui.new_child(UiBuilder::new().id_salt(("icon-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(body.intersect(ui.clip_rect()));
egui::ScrollArea::vertical()
.id_salt(self.id.with(("icon-scroll", first)))
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL)
.show(&mut child, |ui| {
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover());
self.icon_groups(ui, column, area, body, edge);
});
} else {
self.icon_groups(ui, column, body, body, edge);
}
}
/// Icon groups in `area` (the scrolled content when the column scrolls), their drop bands
/// clipped to the visible `view`.
fn icon_groups(&mut self, ui: &mut Ui, column: &Column<P>, area: Rect, view: Rect, edge: Edge) {
let open = flyout::<P>(ui.ctx(), self.id);
let labels = view.width() >= self.style.iconic_label_width;
let xs = view.x_range();
let mut top = area.top();
let mut bands = Vec::new();
for (index, group) in column.groups.iter().enumerate() {
let Some(first) = group.panels.first() else { continue };
if index > 0 {
let y = top + SEPARATOR * 0.5;
ui.painter().with_clip_rect(view).hline(area.x_range().shrink(4.0), y, self.style.border);
top += SEPARATOR;
}
let rect = Rect::from_min_size(pos2(area.left(), top), vec2(area.width(), self.icon_group_height(group)));
let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP));
self.icon_grip(ui, group, grip, column.width);
let showing = open.as_ref().filter(|p| group.panels.contains(p));
let mut y = grip.bottom();
for panel in &group.panels {
let cell = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), self.row_height()));
self.icon(ui, panel, cell, labels, showing);
y = cell.bottom();
}
// The state again: a click above may have opened or closed this group's flyout.
if flyout::<P>(ui.ctx(), self.id).is_some_and(|p| group.panels.contains(&p)) {
self.iconic.request = Some(Request { group: group.clone(), icons: rect.intersect(view), column: view, edge, width: column.width });
}
bands.push((first.clone(), rect));
top = rect.bottom();
}
// P9: the upper half of an icon group inserts above it, the lower half and the room
// under the last group below it. The line sits on the group's edge.
let count = bands.len();
for (index, (anchor, rect)) in bands.into_iter().enumerate() {
let middle = rect.center().y;
let upper_top = if index == 0 { view.top() } else { rect.top() - SEPARATOR * 0.5 };
let lower_bottom = if index + 1 == count { view.bottom() } else { rect.bottom() + SEPARATOR * 0.5 };
for (below, ys, line) in [(false, upper_top..=middle, rect.top()), (true, middle..=lower_bottom, rect.bottom())] {
let band = Rect::from_x_y_ranges(xs, ys).intersect(view);
if usable(band) {
let line = horizontal_line(line.clamp(view.top() + 2.0, view.bottom() - 2.0), xs);
self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line });
}
}
}
}
/// The grip above an icon group drags the whole group (S8).
fn icon_grip(&mut self, ui: &mut Ui, group: &Group<P>, grip: Rect, width: f32) {
let Some(first) = group.panels.first() else { return };
let movable = group.panels.iter().all(|p| self.permissions(p).movable);
let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag());
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label));
let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text };
let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect()));
painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y - 1.0, Stroke::new(1.0, color));
painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color));
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 height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT);
let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height)));
let drag = GroupDrag {
panel: first.clone(),
original: [source.left(), source.top(), source.width(), source.height()],
gesture: GroupGesture::Group,
members: group.panels.clone(),
origin,
source,
};
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
close_flyout::<P>(ui.ctx(), self.id);
}
}
/// One panel's icon: a click opens, switches or closes the flyout (M5); a drag moves the
/// panel (S7). `showing` is the panel whose icon opened this group's flyout.
fn icon(&mut self, ui: &mut Ui, panel: &P, cell: Rect, labels: bool, showing: Option<&P>) {
let movable = self.permissions(panel).movable;
let label = self.label_of(panel);
let response = ui.interact(cell, self.id.with(("icon", panel)), if movable { Sense::click_and_drag() } else { Sense::click() });
self.register_interaction(ui.ctx(), response.id);
let current = showing == Some(panel);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), current, &label));
let painter = ui.painter().with_clip_rect(cell.intersect(ui.clip_rect()));
if current || response.hovered() {
painter.rect_filled(cell.shrink(2.0), 3.0, self.style.active_background);
}
if response.has_focus() {
painter.rect_stroke(cell.shrink(1.0), 3.0, Stroke::new(1.0, self.style.accent), StrokeKind::Inside);
}
let size = self.style.icon_size;
let inset = ((self.style.iconic_width - size) * 0.5).max(2.0);
let icon = Rect::from_min_size(pos2(cell.left() + inset, cell.center().y - size * 0.5), vec2(size, size));
let color = if current { self.style.text } else { self.style.inactive_text };
if let Some(source) = self.content.icon(panel) {
egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell));
} else {
let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect();
painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside);
painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color);
}
if labels {
let text = self.content.label(ui, panel, &label, &self.style.font, color);
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone());
painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color);
} else if response.hovered() && !response.dragged() {
response.clone().on_hover_text(&label);
}
if response.clicked() {
if current {
close_flyout::<P>(ui.ctx(), self.id);
} else {
if showing.is_none() {
ui.ctx().move_to_top(FloatingFrame::layer(self.id.with("flyout")));
}
open_flyout(ui.ctx(), self.id, panel.clone());
self.output.actions.push(crate::docking::Action::Activate { panel: panel.clone() });
self.output.focused = Some(panel.clone());
}
}
if movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), cell) {
close_flyout::<P>(ui.ctx(), self.id);
}
}
/// The Auto-Collapse Iconic Panels item of a column menu (U1): true when chosen.
pub(super) fn auto_collapse_item(&mut self, ui: &mut Ui) -> bool {
let logical = self.style.auto_collapse_label.clone();
let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text);
let mut on = self.iconic.auto_collapse;
let response = ui.add(egui::Checkbox::new(&mut on, text));
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), self.iconic.auto_collapse, &logical));
if response.clicked() {
ui.close();
}
response.clicked()
}
/// Draw the flyout an iconic column asked for, beside the column on the canvas side, or
/// forget a flyout whose group is no longer iconic. With Auto-Collapse, Escape or a press
/// outside the flyout and its column closes it.
pub(in crate::docking) fn flyout(&mut self, ctx: &Context) {
let Some(request) = self.iconic.request.take() else {
close_flyout::<P>(ctx, self.id);
return;
};
let Some(active) = request.group.active_panel().cloned() else { return };
let id = self.id.with("flyout");
if self.iconic.auto_collapse && self.flyout_dismissed(ctx, id, request.column) {
close_flyout::<P>(ctx, self.id);
return;
}
let screen = ctx.content_rect();
let bounds = self.workspace.intersect(screen);
let width = request.width.max(self.style.min_pane).min(bounds.width());
let height = self
.content
.floating_size(&active)
.map(|size| size.y)
.or(request.group.height)
.filter(|h| h.is_finite() && *h > 0.0)
.unwrap_or(FLYOUT_HEIGHT)
.max(self.style.tab_height + self.style.min_pane)
.min(bounds.height());
let left = match request.edge {
Edge::Left => request.column.right(),
Edge::Right => request.column.left() - width,
};
let top = request.icons.top().min(bounds.bottom() - height).max(bounds.top());
let rect = Rect::from_min_size(pos2(left, top), vec2(width, height));
let style = ChromeStyle::from_dock(self.style);
let group = request.group;
let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| {
self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock);
});
self.iconic.rect = Some(response.rect);
}
/// Escape (outside a drag) or a primary press outside the flyout, its column and any menu.
fn flyout_dismissed(&self, ctx: &Context, id: Id, column: Rect) -> bool {
let escape = !is_dragging::<P>(ctx, self.id) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape));
let press = ctx.input(|i| i.pointer.primary_pressed().then(|| i.pointer.press_origin()).flatten());
let outside = press.is_some_and(|pos| {
let inside = ctx.memory(|m| m.area_rect(id)).is_some_and(|r| r.contains(pos)) || column.contains(pos);
let menu = ctx.layer_id_at(pos).is_some_and(|layer| matches!(layer.order, Order::Foreground | Order::Tooltip));
!inside && !menu
});
escape || outside
}
}
+8 -1
View File
@@ -9,6 +9,7 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node
mod dock;
mod floating;
mod frame;
mod iconic;
#[cfg(feature = "native-windows")]
mod native;
@@ -16,6 +17,9 @@ pub(in crate::docking) use frame::{FrameState, view};
pub(in crate::docking) use group::GroupKind;
pub(in crate::docking) use iconic::Iconic;
pub use iconic::{close_flyout, flyout, open_flyout};
pub(in crate::docking) use floating::RESIZE_HANDLES;
mod group;
mod preview;
@@ -35,6 +39,8 @@ pub(super) struct Renderer<'a, P> {
pub(super) owned_interactions: Vec<Id>,
pub(super) output: DockOutput<P>,
pub(super) frame: FrameState<P>,
/// [D] Flyout and iconic column state for this frame.
pub(super) iconic: Iconic<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
@@ -123,6 +129,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None);
let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max);
let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click());
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, self.label_of(&entry.panel))
});
@@ -222,7 +229,7 @@ pub(in crate::docking) fn stripless<P>(panels: &[P], content: &dyn DockContent<P
pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, content: &dyn DockContent<P>) -> PanelLimits {
match node {
Node::Dock { columns, .. } => {
let width: f32 = columns.iter().map(dock::column_width).sum();
let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum();
PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() }
}
Node::Tabs { panels, .. } => {
+1 -1
View File
@@ -269,7 +269,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) {
return size;
}
let mut size = source;
let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT));
if matches!(self.layout.place(panel), Some(Place::Dock { .. })) {
size.y = size.y.min(DOCKED_FLOAT_HEIGHT);
}
+35 -1
View File
@@ -59,6 +59,16 @@ pub struct DockStyle {
pub seam_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] Side of a panel icon in an iconic column; each icon row is 10 pt taller.
pub icon_size: f32,
/// [D] Width of an iconic column without a saved width, and the narrowest it gets.
pub iconic_width: f32,
/// [D] Widest an iconic column gets by dragging its inner edge.
pub iconic_max_width: f32,
/// [D] An iconic column at least this wide shows panel names beside the icons.
pub iconic_label_width: f32,
/// [D] Column menu item that toggles Auto-Collapse Iconic Panels.
pub auto_collapse_label: String,
/// [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,
@@ -123,6 +133,11 @@ impl DockStyle {
column_bar_height: 14.0,
seam_band: 8.0,
new_dock_band: 12.0,
icon_size: 20.0,
iconic_width: 36.0,
iconic_max_width: 240.0,
iconic_label_width: 96.0,
auto_collapse_label: "Auto-Collapse Iconic Panels".into(),
right_to_left: false,
move_column_label: "Move panel column".into(),
collapse_to_icons_label: "Collapse to Icons".into(),
@@ -153,9 +168,14 @@ impl DockStyle {
self.column_bar_height,
self.seam_band,
self.new_dock_band,
self.icon_size,
self.iconic_width,
self.iconic_max_width,
self.iconic_label_width,
]
.iter()
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
&& self.iconic_width <= self.iconic_max_width
&& self.tab_height > 0.0
&& self.font.size > 0.0
}
@@ -187,6 +207,9 @@ 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 },
/// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its
/// preference and passes it to [`DockArea::auto_collapse_iconic`].
ToggleAutoCollapse,
}
/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay.
@@ -448,11 +471,19 @@ pub struct DockArea<'a, P = ()> {
blockers: Vec<Rect>,
maximized: Option<P>,
hide: HidePanels<'a, P>,
auto_collapse: bool,
}
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(), maximized: None, hide: HidePanels::None }
Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true }
}
/// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press
/// outside it. Off, it stays open until its icon or another icon is clicked.
pub fn auto_collapse_iconic(mut self, on: bool) -> Self {
self.auto_collapse = on;
self
}
/// Maximize Frame: the docked frame holding `panel` fills the area; other frames and
@@ -620,14 +651,17 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
owned_interactions: Vec::new(),
output: DockOutput::default(),
frame,
iconic: super::render::Iconic::new(self.auto_collapse),
};
if let Some(root) = &view.root {
renderer.node(ui, root, rect, &mut Vec::new());
ui.allocate_rect(rect, Sense::hover());
}
renderer.flyout(ui.ctx());
renderer.floating(ui.ctx(), &view.floating);
renderer.finish_group_drag(ui.ctx());
renderer.finish_drag(ui.ctx());
super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions);
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
renderer.output
}
+222
View File
@@ -0,0 +1,222 @@
//! Drive a [`DockArea`] with real egui input in application tests (feature `testing`).
//!
//! ```ignore
//! let mut driver = DockDriver::new(app_layout(), Panels::default());
//! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap();
//! driver.drag(bar.center(), egui::pos2(4.0, 300.0));
//! assert!(driver.layout.dock_path(Side::Start).is_some());
//! ```
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, epaint::ClippedShape, vec2};
use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout};
/// Pointer travel that starts a drag before moving to its destination.
const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0);
type StyleFn = Box<dyn Fn(&mut DockStyle)>;
/// A [`DockArea`] in a headless egui context, with its layout and content. Each frame advances
/// the clock by 1/60 s, so double-clicks register. [`Self::step`] applies what a frame emits,
/// the way an application's command path would.
pub struct DockDriver<P, C> {
pub ctx: Context,
pub layout: Layout<P>,
pub content: C,
/// The area's id, for [`DockArea::hit_rects`] and the free docking functions.
pub id: Id,
/// The window, 800x600 by default.
pub screen: Rect,
/// Where the area draws inside the window; the whole window by default.
pub area: Rect,
/// Whether the window has focus. Losing it cancels gestures (C2).
pub focused: bool,
/// Modifiers held for the following events.
pub modifiers: Modifiers,
/// Passed to [`DockArea::auto_collapse_iconic`].
pub auto_collapse: bool,
/// Passed to [`DockArea::maximized`].
pub maximized: Option<P>,
/// Passed to [`DockArea::raise_panel`] on the next frame only.
pub raise: Option<P>,
style: Option<StyleFn>,
shapes: Vec<ClippedShape>,
time: f64,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> DockDriver<P, C> {
/// A driver whose first frames have settled, so every control has its rect.
pub fn new(layout: Layout<P>, content: C) -> Self {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let mut driver = Self {
ctx: Context::default(),
layout,
content,
id: Id::new("dock-driver"),
screen,
area: screen,
focused: true,
modifiers: Modifiers::NONE,
auto_collapse: true,
maximized: None,
raise: None,
style: None,
shapes: Vec::new(),
time: 0.0,
};
driver.settle();
driver
}
/// Adjust the style each frame, after [`DockStyle::from_ui`]. Settles again.
pub fn with_style(mut self, style: impl Fn(&mut DockStyle) + 'static) -> Self {
self.style = Some(Box::new(style));
self.settle();
self
}
/// Draw in `area` of the window. Settles again.
pub fn with_area(mut self, area: Rect) -> Self {
self.area = area;
self.settle();
self
}
/// Render one frame with `events` and return its output. Nothing is applied.
pub fn frame(&mut self, events: Vec<Event>) -> DockOutput<P> {
let mut output = DockOutput::default();
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(self.screen),
events: std::iter::once(Event::ModifiersChanged(self.modifiers)).chain(events).collect(),
focused: self.focused,
time: Some(self.time),
..Default::default()
};
let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id);
let (style_fn, maximized, raise, auto) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse);
let mut full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
if let Some(adjust) = style_fn {
adjust(&mut style);
}
ui.scope_builder(UiBuilder::new().max_rect(area), |ui| {
let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto);
if let Some(panel) = raise.clone() {
dock = dock.raise_panel(panel);
}
output = dock.show_content(ui, layout, &style, content);
});
});
full.textures_delta.clear();
self.shapes = full.shapes;
output
}
/// Render one frame and apply its actions, all or none, with the content's permissions. A
/// rejected batch is reported in the output's `error`.
pub fn step(&mut self, events: Vec<Event>) -> DockOutput<P> {
let mut output = self.frame(events);
if output.error.is_none()
&& let Err(error) = self.layout.apply_all(output.actions.clone(), |p| self.content.permissions(p))
{
output.error = Some(error);
}
output
}
/// Empty frames until moved widgets report their rects; egui reports them a frame late.
pub fn settle(&mut self) {
for _ in 0..3 {
self.step(Vec::new());
}
}
pub fn move_to(&mut self, pos: Pos2) -> DockOutput<P> {
self.step(vec![Event::PointerMoved(pos)])
}
pub fn press(&mut self, pos: Pos2) -> DockOutput<P> {
self.step(vec![Event::PointerMoved(pos), self.button(pos, true)])
}
pub fn release(&mut self, pos: Pos2) -> DockOutput<P> {
self.step(vec![Event::PointerMoved(pos), self.button(pos, false)])
}
/// Press and release at `pos`; the output is the release frame's.
pub fn click(&mut self, pos: Pos2) -> DockOutput<P> {
self.press(pos);
self.release(pos)
}
/// Press at `from`, move past the drag threshold and on to `to`, without releasing.
pub fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput<P> {
self.press(from);
self.move_to(from + NUDGE);
self.move_to(to)
}
/// A whole drag; the output is the release frame's.
pub fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput<P> {
self.hold(from, to);
self.release(to)
}
/// Press and release `key` in one frame.
pub fn key(&mut self, key: Key) -> DockOutput<P> {
let modifiers = self.modifiers;
self.step(vec![
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
])
}
/// Give or take the window's focus for this and the following frames.
pub fn focus(&mut self, focused: bool) -> DockOutput<P> {
self.focused = focused;
self.step(vec![Event::WindowFocused(focused)])
}
/// The pointer leaves the window.
pub fn leave(&mut self) -> DockOutput<P> {
self.step(vec![Event::PointerGone])
}
/// Every gesture source of the last frame.
pub fn hits(&self) -> Vec<(Hit<P>, Rect)> {
DockArea::<P>::hit_rects(&self.ctx, self.id)
}
/// The rect of one gesture source in the last frame.
pub fn hit(&self, hit: &Hit<P>) -> Option<Rect> {
self.hits().into_iter().find(|(h, _)| h == hit).map(|(_, rect)| rect)
}
/// The shapes of the last frame.
pub fn shapes(&self) -> &[ClippedShape] {
&self.shapes
}
/// Whether the last frame filled `rect` (an insertion line or a split preview).
pub fn filled(&self, rect: Rect) -> bool {
self.shapes.iter().any(|clipped| match &clipped.shape {
egui::Shape::Rect(shape) => shape.rect == rect && shape.fill != egui::Color32::TRANSPARENT,
_ => false,
})
}
/// Whether the last frame outlined `rect` without filling it.
pub fn outlined(&self, rect: Rect) -> bool {
self.shapes.iter().any(|clipped| match &clipped.shape {
egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == egui::Color32::TRANSPARENT && shape.stroke.width > 0.0,
_ => false,
})
}
fn button(&self, pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: self.modifiers }
}
}