docking: dock profile with columns, column bars, seams and dock targets

Node::Dock renders as side docks: columns from the window edge inwards,
each with a column bar (drag, »/« button, double-click and menu toggle
iconic) above stacked groups. A split beside a dock gives the dock its
columns' width. Every column's inner edge resizes it; dividers between
open groups save both heights; minimized groups keep theirs. A column
whose groups' minimum heights do not fit scrolls.

Drops resolve dock targets before group targets: column edges (new
column), seams and the top or bottom quarter of a group body (new group),
the room under a minimized column (below its last group) and the left or
right edge of the docked area without a dock (new dock, offered once the
layout has a dock). They show a horizontal or full-height vertical line.
Floating windows and host overlays occlude them. Tab drags use the same
targets with Move. A column bar drag moves the column with a ghost and
floats its groups away from a dock; Escape, focus loss, pointer exit and
cancel_drag cancel it like a group drag.

Side::Start maps to the left edge, or the right with
DockStyle::right_to_left. New DockStyle fields: column_bar_height,
seam_band, edge_band, right_to_left and the column labels.

Behaviour follows vectorcraft crates/ui-egui/src/floating.rs (drop
geometry, column layout and scrolling) and photocraft
crates/ui-egui/src/dock.rs (group heights).
This commit is contained in:
aiongg committed 2026-10-19 05:15:14 +00:00
1 parent 4bf11647ad
commit f1a3683cc9
6 files changed
+656 -27

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
//! [D] The dock profile: side docks of columns, each a column bar above stacked groups.
//!
//! Columns run from the window edge inwards. Every column's inner edge resizes it; the last
//! column's inner edge is the dock's inner edge. Behaviour follows VectorCraft `floating.rs`
//! (column layout, drop geometry) and PhotoCraft `dock.rs` (group heights).
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use super::{GroupKind, Renderer};
use crate::docking::{
Action, Column, Group, GroupDrag, GroupGesture, Node, 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;
/// The physical window edge a dock sits on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::docking) enum Edge {
Left,
Right,
}
/// `Start` is the left edge in a left-to-right layout and the right edge in a right-to-left one.
pub(in crate::docking) fn edge_of(side: Side, right_to_left: bool) -> Edge {
match (side, right_to_left) {
(Side::Start, false) | (Side::End, true) => Edge::Left,
(Side::Start, true) | (Side::End, false) => Edge::Right,
}
}
fn side_at(edge: Edge, right_to_left: bool) -> Side {
match (edge, right_to_left) {
(Edge::Left, false) | (Edge::Right, true) => Side::Start,
(Edge::Left, true) | (Edge::Right, false) => Side::End,
}
}
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 }
}
/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved
/// height, minimum). Minimized groups take `strip`, groups with a saved height keep it, and the
/// rest share what is left; when every open group has a height, the last one takes the rest.
/// Saved heights shrink towards their minimums before the column overflows and scrolls.
pub(in crate::docking) fn group_heights(groups: &[(bool, Option<f32>, f32)], strip: f32, room: f32) -> Vec<f32> {
let open: Vec<usize> = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.0)).collect();
let mut shared: Vec<usize> = open.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| g.1.is_none_or(|h| !h.is_finite()))).collect();
if shared.is_empty() {
shared.extend(open.last());
}
let mut heights: Vec<f32> = groups
.iter()
.enumerate()
.map(|(i, (minimized, height, min))| {
if *minimized {
strip
} else if shared.contains(&i) {
0.0
} else {
height.unwrap_or(*min).max(*min)
}
})
.collect();
let mins: Vec<f32> = groups.iter().map(|g| if g.0 { strip } else { g.2 }).collect();
let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum();
let deficit = heights.iter().sum::<f32>() + shared_min - room;
let slack: f32 = open.iter().filter(|i| !shared.contains(i)).filter_map(|i| Some(heights.get(*i)? - mins.get(*i)?)).sum();
if deficit > 0.0 && slack > 0.0 {
let take = deficit.min(slack) / slack;
for i in open.iter().filter(|i| !shared.contains(i)) {
if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) {
*height -= (*height - min) * take;
}
}
}
let free = (room - heights.iter().sum::<f32>()).max(0.0);
let share = free / shared.len().max(1) as f32;
for i in &shared {
if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) {
*height = share.max(*min);
}
}
heights
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// A horizontal split with a dock child: the dock takes its columns' width on its physical
/// edge, the sibling the rest. False when neither child is a dock.
pub(super) fn dock_split(&mut self, ui: &mut Ui, first: &Node<P>, second: &Node<P>, rect: Rect, path: &mut Vec<bool>) -> bool {
let (dock_first, side, columns, other) = match (first, second) {
(Node::Dock { side, columns }, other) => (true, *side, columns, other),
(other, Node::Dock { side, columns }) => (false, *side, columns, other),
_ => return false,
};
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 (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())),
Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max),
),
Edge::Right => (
Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max),
Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())),
),
};
self.dock(ui, side, columns, dock, room);
path.push(dock_first);
self.node(ui, other, rest, path);
path.pop();
true
}
/// One dock in `rect`, columns from its outer edge. `room` is the widest the dock may get.
pub(super) fn dock(&mut self, ui: &mut Ui, side: Side, columns: &[Column<P>], rect: Rect, room: f32) {
let edge = edge_of(side, self.style.right_to_left);
let (outer, inward) = match edge {
Edge::Left => (rect.left(), 1.0),
Edge::Right => (rect.right(), -1.0),
};
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),
rect.y_range(),
));
self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
return;
}
if usable(rect) {
ui.painter().rect_filled(rect, 0.0, self.style.background);
}
let total: f32 = columns.iter().map(column_width).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 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);
if index == 0 {
let (band, line) = column_edge(x, inward, rect.y_range());
self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: false }, band, line });
}
let (band, line) = column_edge(inner, -inward, rect.y_range());
self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line });
drawn.push((column, first, width, inner));
x = inner;
}
// 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 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());
let response = ResizeHandle::new(
self.id.with(("column-resize", Some(first))),
SplitAxis::Horizontal,
divider,
&self.style.resize_label,
self.handle_style(),
)
.value(width, lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
let next = (width + inward * response.delta).clamp(lo, hi);
if response.delta != 0.0 && next != width {
self.output.actions.push(if column.iconic {
Action::ResizeIconic { panel: first.clone(), width: Some(next) }
} else {
Action::ResizeColumn { panel: first.clone(), width: next }
});
}
}
}
fn handle_style(&self) -> ResizeHandleStyle {
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }
}
/// Width bounds of an expanded column from its panels' limits.
fn column_bounds(&self, column: &Column<P>) -> (f32, f32) {
let mut lo = self.style.min_pane;
let mut hi = f32::INFINITY;
for panel in column.groups.iter().flat_map(|g| g.panels.iter()) {
let limits = self.limits(panel);
lo = lo.max(limits.min.x);
hi = hi.min(limits.max.x);
}
(lo, hi.max(lo))
}
/// Least height of an open group: its strip and the tallest minimum of its panels.
fn group_min(&self, group: &Group<P>) -> f32 {
let body = group.panels.iter().map(|p| self.limits(p).min.y).fold(self.style.min_pane, f32::max);
self.style.tab_height + body
}
fn column(&mut self, ui: &mut Ui, column: &Column<P>, first: &P, rect: Rect, edge: Edge) {
if !usable(rect) {
return;
}
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) {
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
return;
}
let sizes: Vec<_> = column.groups.iter().map(|g| (g.minimized, g.height, self.group_min(g))).collect();
let heights = group_heights(&sizes, self.style.tab_height, body.height());
let total: f32 = heights.iter().sum();
let start = self.targets.len();
if total > body.height() + 0.5 {
let mut child =
ui.new_child(UiBuilder::new().id_salt(("dock-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(("column-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 (content, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover());
self.column_groups(ui, column, &heights, content, body);
});
} else {
self.column_groups(ui, column, &heights, body, body);
}
// A group scrolled out of view takes nothing.
for target in self.targets.iter_mut().skip(start) {
target.rect = target.rect.intersect(body);
target.tab_strip = target.tab_strip.map(|strip| strip.intersect(body));
for (_, tab) in &mut target.tabs {
*tab = tab.intersect(body);
}
}
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
}
/// Groups top to bottom in `area` (the scrolled content when the column scrolls), their
/// seams and bands clipped to the visible `view`, and the height dividers.
fn column_groups(&mut self, ui: &mut Ui, column: &Column<P>, heights: &[f32], area: Rect, view: Rect) {
let mut top = area.top();
let mut rects = Vec::new();
for (group, height) in column.groups.iter().zip(heights) {
let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height));
self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Dock);
rects.push(rect);
top += height;
}
let xs = view.x_range();
let half = self.style.seam_band * 0.5;
let push = |targets: &mut Vec<DockDrop<P>>, target: DockTarget<P>, band: Rect, line: Rect| {
let band = band.intersect(view);
if usable(band) {
targets.push(DockDrop { target, band, line: line.intersect(view.expand(2.0)) });
}
};
let mut previous: Option<&P> = None;
for (group, rect) in column.groups.iter().zip(&rects) {
let Some(anchor) = group.panels.first() else { continue };
if let Some(above) = previous {
let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half));
push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, horizontal_line(rect.top(), xs));
}
if !group.minimized {
let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + self.style.tab_height).min(rect.bottom())), rect.max);
let quarter = body.height() * 0.25;
let upper = Rect::from_x_y_ranges(xs, body.top()..=(body.top() + quarter));
let lower = Rect::from_x_y_ranges(xs, (body.bottom() - quarter)..=body.bottom());
push(&mut self.dock_targets, DockTarget::GroupBand { anchor: anchor.clone(), below: false }, upper, horizontal_line(rect.top(), xs));
push(
&mut self.dock_targets,
DockTarget::GroupBand { anchor: anchor.clone(), below: true },
lower,
horizontal_line(rect.bottom(), xs),
);
}
previous = Some(anchor);
}
// The room under the last group, when every group is minimized, stacks below it.
if let Some(last) = previous
&& top < view.bottom()
{
let band = Rect::from_x_y_ranges(xs, top..=view.bottom());
push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs));
}
// Height dividers between an open group and the next open one, after the groups so
// their hit areas win at the boundary. Both groups' heights are saved.
let open: Vec<usize> = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect();
for pair in open.windows(2) {
let &[i, j] = pair else { continue };
let (Some(upper), Some(lower), Some(rect)) = (column.groups.get(i), column.groups.get(j), rects.get(i)) else { continue };
let (Some(a), Some(b), Some(first), Some(second)) = (heights.get(i), heights.get(j), upper.panels.first(), lower.panels.first()) else {
continue;
};
let (lo, hi) = (self.group_min(upper), a + b - self.group_min(lower));
let hi = hi.max(lo);
let divider = Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 1.0)..=(rect.bottom() + 1.0));
let response = ResizeHandle::new(
self.id.with(("group-resize", Some(first))),
SplitAxis::Vertical,
divider,
&self.style.resize_label,
self.handle_style(),
)
.value(*a, lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
let next = (a + response.delta).clamp(lo, hi);
if response.delta != 0.0 && next != *a {
self.output.actions.push(Action::ResizeGroup { panel: first.clone(), height: Some(next) });
self.output.actions.push(Action::ResizeGroup { panel: second.clone(), height: Some(a + b - next) });
}
}
}
/// The column bar: the »/« button at its outer end, the rest drags the column (S6),
/// toggles iconic on double-click (M2) and opens the column menu (U1).
fn column_bar(&mut self, ui: &mut Ui, column: &Column<P>, first: &P, bar: Rect, rect: Rect, edge: Edge) {
ui.painter().rect_filled(bar, 0.0, self.style.tab_background);
let width = COLLAPSE_WIDTH.min(bar.width());
let (button, grip) = match edge {
Edge::Left => (
Rect::from_min_max(bar.min, pos2(bar.left() + width, bar.bottom())),
Rect::from_min_max(pos2(bar.left() + width, bar.top()), bar.max),
),
Edge::Right => (
Rect::from_min_max(pos2(bar.right() - width, bar.top()), bar.max),
Rect::from_min_max(bar.min, pos2(bar.right() - width, bar.bottom())),
),
};
let label = if column.iconic { self.style.expand_panels_label.clone() } else { self.style.collapse_to_icons_label.clone() };
if usable(button) {
let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click());
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &label));
// The arrows point where the column goes: towards the window edge when it collapses.
let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" };
let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text };
ui.painter().with_clip_rect(button).text(button.center(), egui::Align2::CENTER_CENTER, glyph, self.style.font.clone(), color);
if response.clicked() {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
}
if !usable(grip) {
return;
}
let movable = column.groups.iter().flat_map(|g| g.panels.iter()).all(|p| self.permissions(p).movable);
let response = ui.interact(grip, self.id.with(("column-bar", Some(first))), Sense::click_and_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_column_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: [rect.left(), rect.top(), rect.width(), rect.height()],
gesture: GroupGesture::Column,
members: column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(),
origin,
source: rect,
};
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
}
if response.double_clicked() {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
for k in 0..2 {
let y = grip.center().y - 1.5 + k as f32 * 3.0;
let x = grip.center().x;
ui.painter().with_clip_rect(grip).line_segment([pos2(x - 9.0, y), pos2(x + 9.0, y)], Stroke::new(1.0, self.style.inactive_text));
}
if response.has_focus() || response.hovered() {
ui.painter().with_clip_rect(grip).rect_stroke(
grip.shrink(1.0),
0.0,
Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)),
StrokeKind::Inside,
);
}
let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space));
let command = if response.secondary_clicked() || keyboard {
Some(egui::SetOpenCommand::Bool(true))
} else if response.clicked() {
Some(egui::SetOpenCommand::Bool(false))
} else {
None
};
egui::Popup::menu(&response).open_memory(command).show(|ui| {
if self.menu_action(ui, &label) {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
});
}
/// Whether a floating window (other than the one following the pointer) or a host overlay
/// 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.layout.floating.iter().any(|window| {
let Some(first) = window.panels().next() else { return false };
!moving.is_some_and(|p| window.contains(p))
&& ctx.memory(|m| m.area_rect(self.id.with(("floating", first)))).is_some_and(|r| r.contains(pointer))
})
}
/// 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) {
return candidates;
}
let rtl = self.style.right_to_left;
let ys = self.workspace.y_range();
for edge in [Edge::Left, Edge::Right] {
let side = side_at(edge, rtl);
if self.layout.dock_path(side).is_some() {
continue;
}
let (x, inward) = match edge {
Edge::Left => (self.workspace.left(), self.style.edge_band),
Edge::Right => (self.workspace.right(), -self.style.edge_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);
candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
}
candidates
}
/// The dock target a whole group or column drag would take at `pointer`, with the action
/// and its line. A target that leaves the layout unchanged is a no-op: `None` action.
pub(super) fn dock_group_proposal(&self, ctx: &Context, drag: &GroupDrag<P>, pointer: Pos2) -> Option<(Option<Action<P>>, Rect)> {
let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel);
if self.dock_covered(ctx, pointer, moving) {
return None;
}
let candidates = self.dock_candidates();
let drop = resolve_dock(pointer, &candidates)?;
let action = group_action(drag, drop.target.to());
self.checked(action).map(|action| (action, drop.line))
}
/// The dock target a single tab drag would take: a new group or column of that panel. A new
/// dock only takes a panel that is alone in its group.
pub(super) fn dock_tab_proposal(&self, ctx: &Context, panel: &P, pointer: Pos2) -> Option<(Option<Action<P>>, Rect)> {
if self.dock_covered(ctx, pointer, None) {
return None;
}
let candidates = self.dock_candidates();
let drop = resolve_dock(pointer, &candidates)?;
let action = match drop.target.to() {
Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement },
to @ Target::NewDock { .. }
if self.layout.place(panel).and_then(|place| self.layout.group(&place)).is_some_and(|g| g.panels.len() == 1) =>
{
Action::MoveGroup { panel: panel.clone(), to }
}
Target::NewDock { .. } | Target::Float { .. } => return None,
};
self.checked(action).map(|action| (action, drop.line))
}
/// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid.
fn checked(&self, action: Action<P>) -> Option<Option<Action<P>>> {
let mut candidate = self.layout.clone();
candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).ok()?;
Some((candidate != *self.layout).then_some(action))
}
/// 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);
}
}
}
/// The move a group gesture makes: the column for a column 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 },
}
}
+4 -1
View File
@@ -251,6 +251,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized });
}
}
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()) {
let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
self.output.actions.push(Action::MoveGroup {
@@ -281,7 +284,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// A stock menu button with shaped text and its logical accessibility label; true when
/// clicked, after closing the menu.
fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool {
pub(super) fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool {
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(), logical));
+9 -12
View File
@@ -6,6 +6,7 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split
use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions};
mod dock;
mod floating;
pub(in crate::docking) use group::GroupKind;
@@ -24,6 +25,8 @@ pub(super) struct Renderer<'a, P> {
/// The docked area, for host drop requests.
pub(super) workspace: Rect,
pub(super) targets: Vec<DropTarget<P>>,
/// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`].
pub(super) dock_targets: Vec<crate::docking::targets::DockDrop<P>>,
pub(super) owned_interactions: Vec<Id>,
pub(super) output: DockOutput<P>,
}
@@ -63,6 +66,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
return;
}
match node {
Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {}
Node::Split { axis, size, first, second } => {
let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return };
let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second));
@@ -93,17 +97,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame),
Node::Dock { columns, .. } => {
// Interim rendering: columns side by side, groups stacked evenly.
let total: f32 = columns.iter().map(|c| c.width).sum();
let mut left = rect.left();
for column in columns {
let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 };
let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom()));
left += width;
self.stacked_groups(ui, &column.groups, column_rect, GroupKind::Dock);
}
}
Node::Dock { side, columns } => self.dock(ui, *side, columns, rect, rect.width()),
Node::Stack { entries } => {
let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height());
let remaining = (rect.height() - total_header).max(0.0);
@@ -220,7 +214,10 @@ pub(in crate::docking) fn stripless<P>(panels: &[P], content: &dyn DockContent<P
/// Size policy of a subtree, shared by the renderer and its measurements.
pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, content: &dyn DockContent<P>) -> PanelLimits {
match node {
Node::Dock { .. } => PanelLimits { min: Vec2::splat(style.min_pane), ..PanelLimits::default() },
Node::Dock { columns, .. } => {
let width: f32 = columns.iter().map(dock::column_width).sum();
PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() }
}
Node::Tabs { panels, .. } => {
let mut limits = PanelLimits::default();
for panel in panels {
+30 -11
View File
@@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash};
use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2};
use super::{Renderer, clamp_float};
use super::{Renderer, clamp_float, dock::group_action};
use crate::docking::{
Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop,
};
@@ -32,7 +32,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
&& (down || released)
&& let Some(pointer) = pointer
&& !self.blockers.iter().any(|r| r.contains(pointer))
&& self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture == GroupGesture::Group))
&& self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture != GroupGesture::Window))
{
if released {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(key));
@@ -59,11 +59,11 @@ 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::Group => self.drop_group(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::Group {
if drag.gesture != GroupGesture::Window {
self.paint_group_drag(ctx, &drag, pointer, float_only);
}
}
@@ -83,10 +83,7 @@ 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 = Action::MoveGroup {
panel: drag.panel.clone(),
to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() },
};
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)
}
@@ -97,11 +94,15 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if pointer.distance(drag.origin) < 2.0 {
return;
}
if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) {
self.output.actions.extend(action);
return;
}
let floating = matches!(self.layout.place(&drag.panel), Some(Place::Floating { .. }));
let proposal = if float_only { None } else { self.group_proposal(drag, pointer) };
if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) {
let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() };
self.output.actions.push(Action::MoveGroup { panel: drag.panel.clone(), to });
self.output.actions.push(group_action(drag, to));
return;
}
if proposal.is_some() && !floating {
@@ -110,8 +111,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
// No target: hosts that still apply their own policy for a whole-group release get it.
self.output.group_drop = Some((drag.panel.clone(), pointer));
let rect = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect());
let action =
Action::MoveGroup { panel: drag.panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) } };
let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) });
if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() {
self.output.actions.push(action);
}
@@ -125,6 +125,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if float_only {
return;
}
if let Some((action, line)) = self.dock_group_proposal(ctx, drag, pointer) {
if action.is_some() {
self.paint_dock_line(ctx, line);
}
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 fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT };
@@ -170,6 +176,19 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
return;
}
let dock = if float_only || gone { None } else { self.dock_tab_proposal(ctx, &drag.panel, pointer) };
if let Some((action, line)) = dock {
if released {
cancel_drag::<P>(ctx, self.id);
if let Some(action) = action.filter(|_| pointer.distance(drag.origin) >= 2.0) {
self.output.actions.push(action);
self.output.focused = Some(drag.panel);
}
} else if action.is_some() {
self.paint_dock_line(ctx, line);
}
return;
}
let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) };
if released {
cancel_drag::<P>(ctx, self.id);
+34 -1
View File
@@ -45,6 +45,19 @@ 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,
/// [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.
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] 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,
/// [D] Accessibility label of a column bar.
pub move_column_label: String,
pub collapse_to_icons_label: String,
pub expand_panels_label: String,
}
impl DockStyle {
@@ -77,12 +90,20 @@ impl DockStyle {
drag_bar_width: 40.0,
floating_background: visuals.window_fill,
floating_stroke: visuals.window_stroke,
column_bar_height: 14.0,
seam_band: 8.0,
edge_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(),
}
}
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 > 0.0
&& self.font.size > 0.0
@@ -148,6 +169,8 @@ pub(super) enum GroupGesture {
Window,
/// A drag bar or top border: a ghost follows the pointer, the release picks the target.
Group,
/// [D] A column bar: like `Group`, for the whole dock column.
Column,
}
#[derive(Clone)]
@@ -378,7 +401,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
while let Some((node, path)) = nodes.pop() {
match node {
Node::Tabs { panels, .. } if group(panels) => return true,
Node::Dock { columns, .. } if columns.iter().flat_map(|c| c.groups.iter()).any(|g| group(&g.panels)) => return true,
Node::Dock { columns, .. }
if columns.iter().flat_map(|c| c.groups.iter()).any(|g| {
group(&g.panels)
|| ["column-bar", "column-collapse", "column-resize", "group-resize"]
.iter()
.any(|name| matches(self.id.with((*name, g.panels.first()))))
}) =>
{
return true;
}
Node::Stack { entries } => {
if entries.iter().any(|entry| {
matches(self.id.with(("tab", &entry.panel)))
@@ -485,6 +517,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
layout,
workspace: rect,
targets: Vec::new(),
dock_targets: Vec::new(),
owned_interactions: Vec::new(),
output: DockOutput::default(),
};
+74 -2
View File
@@ -1,6 +1,6 @@
use egui::{Pos2, Rect, pos2};
use egui::{Pos2, Rangef, Rect, pos2};
use super::{Placement, Zone};
use super::{Placement, Side, Target, Zone};
/// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip
/// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas.
@@ -149,3 +149,75 @@ pub fn resolve_drop<P: Clone + Eq>(panel: &P, pointer: Pos2, targets: &[DropTarg
noop: false,
})
}
/// [D] A dock drop target, built once per frame next to the [`DropTarget`]s. The pointer selects
/// it inside `band`; the preview is the line `line`.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct DockDrop<P> {
pub(super) target: DockTarget<P>,
pub(super) band: Rect,
pub(super) line: Rect,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum DockTarget<P> {
/// Column edge: a new column beside the column of `anchor`, further from the window edge
/// when `after` (P5).
ColumnEdge { anchor: P, after: bool },
/// The seam between two stacked groups: a new group below the group of `anchor` (P4).
Seam { anchor: P },
/// The top or bottom quarter of a group body, or the free room under a column's last group (P3).
GroupBand { anchor: P, below: bool },
/// A window edge without a dock, or an empty dock (P5, P6).
NewDock { side: Side },
}
impl<P: Clone> DockTarget<P> {
pub(super) fn to(&self) -> Target<P> {
match self {
Self::ColumnEdge { anchor, after } => Target::At { anchor: anchor.clone(), placement: Placement::Column { after: *after } },
Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true } },
Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below } },
Self::NewDock { side } => Target::NewDock { side: *side },
}
}
/// Rank in resolution order: column edges reach past their columns, so they come first.
fn rank(&self) -> u8 {
match self {
Self::ColumnEdge { .. } => 0,
Self::Seam { .. } => 1,
Self::GroupBand { .. } => 2,
Self::NewDock { .. } => 3,
}
}
}
/// The dock target under the pointer: column edges, then seams, then group bands, then new docks.
pub(super) fn resolve_dock<P: Clone>(pointer: Pos2, targets: &[DockDrop<P>]) -> Option<&DockDrop<P>> {
if !pointer.is_finite() {
return None;
}
targets.iter().filter(|t| usable(t.band) && t.band.contains(pointer)).min_by_key(|t| t.target.rank())
}
/// Width of the insertion lines dock targets preview with.
const LINE: f32 = 4.0;
/// How far a column edge band reaches into the column, and past the edge out of it.
const EDGE_IN: f32 = 6.0;
const EDGE_OUT: f32 = 10.0;
/// Band and line of a column edge at `x`. `inward` is +1 when the column lies right of the edge,
/// -1 when it lies left of it. The band reaches further out of the column than into it.
pub(super) fn column_edge(x: f32, inward: f32, ys: Rangef) -> (Rect, Rect) {
let (a, b) = (x + inward * EDGE_IN, x - inward * EDGE_OUT);
(Rect::from_x_y_ranges(a.min(b)..=a.max(b), ys), vertical_line(x, ys))
}
pub(super) fn vertical_line(x: f32, ys: Rangef) -> Rect {
Rect::from_x_y_ranges((x - LINE * 0.5)..=(x + LINE * 0.5), ys)
}
pub(super) fn horizontal_line(y: f32, xs: Rangef) -> Rect {
Rect::from_x_y_ranges(xs, (y - LINE * 0.5)..=(y + LINE * 0.5))
}