docking: split the renderer into render modules

DockArea, DockContent, DockStyle, DockOutput and the drag state stay in
surface.rs. The Renderer moves to docking/render: the node tree, limits
and helpers in mod.rs, groups and tabs in group.rs, floating windows in
floating.rs, and drop resolution and previews in preview.rs.
This commit is contained in:
aiongg committed 2026-10-19 04:41:18 +00:00
1 parent 69a1adf699
commit 87ddebd73a
6 files changed
+673 -639

No files matched your search

+3
View File
@@ -7,6 +7,7 @@
mod dock_model;
mod model;
mod render;
mod surface;
mod targets;
mod workspace;
@@ -17,6 +18,8 @@ pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, beg
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect};
pub use workspace::Workspace;
use surface::{Drag, GroupDrag};
#[cfg(test)]
mod model_tests;
#[cfg(test)]
+77
View File
@@ -0,0 +1,77 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Key, Rect, Sense, Stroke, Vec2, pos2, vec2};
use super::{Renderer, clamp_float};
use crate::docking::{Action, Node};
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn floating(&mut self, ctx: &Context) {
let screen = ctx.content_rect();
if !crate::docking::targets::usable(screen) {
return;
}
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
let mut groups: Vec<_> = self.layout.floating.iter().collect();
groups.sort_by_key(|group| {
let id = group.panels().next().map(|panel| self.id.with(("floating", panel)));
layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX)
});
for group in groups {
let Some(first) = group.panels().next() else { continue };
let [x, y, width, height] = group.rect;
let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 });
let size = vec2(width, height).max(limits.min).min(limits.max);
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| group.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).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());
self.stacked_groups(ui, &group.groups, rect, true);
let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max);
let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe);
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label));
if response.drag_started() {
response.request_focus();
}
let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { 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;
}
});
}
if response.enabled() && delta != Vec2::ZERO {
let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size());
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] });
}
if response.hovered() || response.dragged() || response.has_focus() {
ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent));
}
});
}
}
}
+214
View File
@@ -0,0 +1,214 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use super::{Renderer, clamp_float};
use crate::docking::{Action, DropTarget, GroupDrag, Permissions, Placement, begin_drag, surface::TAB_PADDING};
use crate::tabs;
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// Groups stacked top to bottom in equal shares of `rect`.
pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[crate::docking::Group<P>], rect: Rect, floating: bool) {
let share = rect.height() / groups.len().max(1) as f32;
for (index, group) in groups.iter().enumerate() {
let top = rect.top() + share * index as f32;
let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share));
self.group(ui, &group.panels, group.active, group_rect, floating);
}
}
pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) {
let Some(selected) = panels.get(active) else { return };
let policy = (self.permissions)(selected);
let bare = panels.len() == 1 && policy == Permissions::PROTECTED;
if bare {
self.contents(ui, selected, rect);
self.targets.push(DropTarget {
anchor: selected.clone(),
rect,
tab_strip: None,
tabs: Vec::new(),
allow_splits: !floating,
accepts_tabs: false,
});
return;
}
let mut 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 floating {
let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom()));
let response = ui.interact(grip, self.id.with(("move-group", panels.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 panels.iter().all(|p| (self.permissions)(p).movable)
&& response.drag_started()
&& let Some(first) = panels.first()
{
ui.ctx().data_mut(|d| {
d.insert_temp(
self.id.with("group-drag"),
GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] },
)
});
}
if panels.iter().all(|p| (self.permissions)(p).movable)
&& response.dragged()
&& ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))).is_some()
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
{
let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect());
if let Some(first) = panels.first() {
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);
strip.min.x = grip.right();
}
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 };
if header_width > 0.0 {
let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max);
let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header));
child.set_clip_rect(header.intersect(ui.clip_rect()));
self.content.group_header(&mut child, panels, selected, floating);
}
let widths: Vec<f32> = panels
.iter()
.map(|p| {
self.content
.label(ui, p, &(self.label)(p), &self.style.font, self.style.text)
.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone())
.size()
.x
+ TAB_PADDING
})
.collect();
let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0);
let mut x = strip.left();
let mut targets = Vec::new();
for (index, width) in &fit.shown {
let Some(panel) = panels.get(*index) else { continue };
let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height()));
self.tab(ui, panel, tab, *index == active, rect);
targets.push((panel.clone(), tab));
x += width;
}
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height()));
let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label));
ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text);
egui::Popup::menu(&response).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for index in &fit.overflow {
if let Some(panel) = panels.get(*index)
&& self.menu_label(ui, panel).clicked()
{
self.output.actions.push(Action::Activate { panel: panel.clone() });
self.output.focused = Some(panel.clone());
ui.close();
}
}
});
});
}
// Include hidden tab identities for source-group detection without creating hit areas.
for panel in panels {
if !targets.iter().any(|(p, _)| p == panel) {
targets.push((panel.clone(), Rect::NOTHING));
}
}
self.targets.push(DropTarget {
anchor: selected.clone(),
rect,
tab_strip: Some(strip),
tabs: targets,
allow_splits: !floating,
accepts_tabs: policy.accepts_tabs,
});
self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max));
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
}
pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response {
let logical = (self.label)(panel);
let text = self.content.label(ui, panel, &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));
response
}
pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) {
let policy = (self.permissions)(panel);
let label = (self.label)(panel);
let color = if active { self.style.text } else { self.style.inactive_text };
// A tab squeezed below its natural width cuts its label with '…' inside the padding its
// natural width was measured with, instead of letting it run under its neighbours.
let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
(rect.width() - TAB_PADDING).max(0.0),
self.style.font.clone(),
);
let elided = galley.elided;
let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active)
.sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() })
.focus_stroke(Stroke::new(1.0, self.style.accent))
.show_at(ui, rect, |ui, response| {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background });
painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color);
});
let response = if elided { response.on_hover_text(&label) } else { response };
self.register_interaction(ui.ctx(), response.id);
if response.clicked() {
self.output.actions.push(Action::Activate { panel: panel.clone() });
self.output.focused = Some(panel.clone());
}
if policy.movable && response.drag_started() {
begin_drag(ui.ctx(), self.id, panel.clone(), group);
}
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 response = ui.button(text);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label));
if response.clicked() {
let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] });
ui.close();
}
}
if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() {
let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text);
let response = ui.menu_button(text, |ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for target in self.layout.move_targets(panel, self.permissions) {
if self.menu_label(ui, target).clicked() {
self.output.actions.push(Action::Move {
panel: panel.clone(),
anchor: target.clone(),
placement: Placement::Tab { before: None },
});
ui.close();
}
}
});
});
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label));
}
if policy.closable {
let text = self.content.action_label(ui, &self.style.close_label, &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.close_label));
if response.clicked() {
self.output.actions.push(Action::Close { panel: panel.clone() });
ui.close();
}
}
});
}
}
+260
View File
@@ -0,0 +1,260 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2};
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect};
use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions};
mod floating;
mod group;
mod preview;
pub(super) struct Renderer<'a, P> {
pub(super) id: Id,
pub(super) style: &'a DockStyle,
pub(super) label: &'a dyn Fn(&P) -> String,
pub(super) permissions: &'a dyn Fn(&P) -> Permissions,
pub(super) limits: &'a dyn Fn(&P) -> PanelLimits,
pub(super) content: &'a mut dyn DockContent<P>,
pub(super) activation: Option<P>,
pub(super) blockers: Vec<Rect>,
pub(super) layout: &'a Layout<P>,
pub(super) targets: Vec<DropTarget<P>>,
pub(super) owned_interactions: Vec<Id>,
pub(super) output: DockOutput<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn register_interaction(&mut self, ctx: &Context, id: Id) {
// A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers,
// floating grips/corners and splits fit comfortably inside this fixed bound.
const CAP: usize = 8 * crate::docking::MAX_PANELS;
if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) {
self.owned_interactions.push(id);
}
// Retain prior-frame controls until return, and publish current controls before
// app callbacks. No Context lock spans a callback or a stop_dragging call.
ctx.data_mut(|d| {
let ids = d.get_temp_mut_or_default::<Vec<Id>>(self.id.with("owned-interactions"));
if ids.len() < CAP && !ids.contains(&id) {
ids.push(id);
}
});
}
pub(in crate::docking) fn node(&mut self, ui: &mut Ui, node: &Node<P>, rect: Rect, path: &mut Vec<bool>) {
if !crate::docking::targets::usable(rect) {
return;
}
match node {
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));
let first_size = wanted.first_size().clamp(lo, hi);
let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return };
path.push(false);
self.node(ui, first, split.first, path);
path.pop();
path.push(true);
self.node(ui, second, split.second, path);
path.pop();
// The divider's inner-edge hit margin must win over child scrollbars, just
// as accordion dividers win over the adjacent panel's content.
let response = ResizeHandle::new(
self.id.with(("split", &*path)),
*axis,
split.divider,
&self.style.resize_label,
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
)
.value(split.first_size(), lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
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 });
}
}
Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false),
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, false);
}
}
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);
let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum();
let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32);
let mut top = rect.top();
let mut dividers = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let share = if entry.open && total_wanted > 0.0 {
(f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32
} else {
0.0
};
let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom())));
let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom())));
self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect);
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());
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel))
});
ui.painter().with_clip_rect(collapse).text(
collapse.center(),
egui::Align2::CENTER_CENTER,
if entry.open { "−" } else { "+" },
self.style.font.clone(),
self.style.text,
);
if response.clicked() {
self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open });
}
let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max);
if entry.open {
self.contents(ui, &entry.panel, body_rect);
}
self.targets.push(DropTarget {
anchor: entry.panel.clone(),
rect: panel_rect,
tab_strip: Some(title_rect),
tabs: vec![(entry.panel.clone(), title_rect)],
allow_splits: true,
accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs,
});
top = panel_rect.bottom();
if entry.open && remaining > 0.0 {
dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect)));
}
}
// Register dividers after the headers so their narrow hit regions win at the
// shared boundary. Closed entries retain their saved sizes and are skipped.
for (index, divider) in dividers {
let Some(first) = entries.get(index) else { continue };
let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue };
let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0));
let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0));
let scale = f64::from(remaining) / total_wanted;
let first_size = (a * scale) as f32;
let pair_size = ((a + b) * scale) as f32;
let body_limits = |panel: &P| {
let mut limits = (self.limits)(panel);
limits.min.y = limits.min.y.max(self.style.min_pane);
limits.max = limits.max.max(limits.min);
limits
};
let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel));
let response = ResizeHandle::new(
self.id.with(("stack-resize", &first.panel)),
SplitAxis::Vertical,
divider,
&self.style.resize_label,
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
)
.value(first_size, lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
if response.delta != 0.0 && scale > 0.0 {
let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale;
let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0));
self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) });
self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) });
}
}
}
}
}
pub(super) fn node_limits(&self, node: &Node<P>) -> PanelLimits {
match node {
Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() },
Node::Tabs { panels, .. } => {
let mut limits = PanelLimits::default();
for panel in panels {
let policy = (self.limits)(panel);
limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane));
limits.max = limits.max.min(policy.max);
}
limits.max = limits.max.max(limits.min);
if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) {
limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0);
limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0);
}
limits
}
Node::Stack { entries } => {
let mut limits = PanelLimits::default();
for entry in entries {
let policy = (self.limits)(&entry.panel);
limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane);
limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height;
limits.max.x = limits.max.x.min(policy.max.x);
}
limits.min = limits.min.min(Vec2::splat(1_000_000.0));
limits.max = limits.max.max(limits.min);
limits
}
Node::Split { axis, first, second, .. } => {
let a = self.node_limits(first);
let b = self.node_limits(second);
let limits = match axis {
SplitAxis::Horizontal => PanelLimits {
min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)),
max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)),
},
SplitAxis::Vertical => PanelLimits {
min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap),
max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap),
},
};
PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) }
}
}
}
pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) {
if !crate::docking::targets::usable(rect) {
return;
}
ui.painter().rect_filled(rect, 0.0, self.style.background);
let mut child =
ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
self.content.body(&mut child, panel);
if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) {
self.output.focused = Some(panel.clone());
}
}
}
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)
}
pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) {
let (a_min, a_max, b_min, b_max) = match axis {
SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x),
SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y),
};
let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 };
let lo = (a_min * scale).min(available);
let hi = (available - b_min * scale).max(lo).min(available);
let constrained_lo = lo.max(available - b_max);
let constrained_hi = hi.min(a_max);
if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) }
}
+106
View File
@@ -0,0 +1,106 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Key, Rect, Stroke, StrokeKind};
use super::{Renderer, clamp_float};
use crate::docking::{Action, Drag, GroupDrag, cancel_drag, resolve_drop};
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))) else { return };
if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
return;
}
let (escape, focused, down, released, pointer) =
ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos()));
// A button release outside the window may never reach egui.
let has_pointer = ctx.input(|i| i.pointer.has_pointer());
// Touch-up also removes the pointer, but still completes a drop.
let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. })));
if escape || !focused || !down || !has_pointer {
cancel_drag::<P>(ctx, self.id);
if escape || !focused {
self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original });
} else if released
&& (has_pointer || touch_ended)
&& let Some(pointer) = pointer.filter(|p| p.is_finite())
&& !self.blockers.iter().any(|r| r.contains(pointer))
{
self.output.group_drop = Some((drag.panel, pointer));
}
} else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) {
self.output.group_drag = Some(drag.panel);
}
}
pub(in crate::docking) fn finish_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) else { return };
let (pointer, down, released, focused, gone) =
ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer()));
// Leaving the window is the drop: on X11 and Windows the release outside never reaches
// egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the
// last position this frame, and the panel floats there rather than docking at the edge.
let released = released || gone;
let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape));
if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) {
cancel_drag::<P>(ctx, self.id);
return;
}
let Some(pointer) = pointer.filter(|p| p.is_finite()) else {
if released {
cancel_drag::<P>(ctx, self.id);
}
return;
};
if self.blockers.iter().any(|rect| rect.contains(pointer)) {
if released {
cancel_drag::<P>(ctx, self.id);
}
return;
}
let proposal = resolve_drop(&drag.panel, pointer, &self.targets);
if released {
cancel_drag::<P>(ctx, self.id);
if pointer.distance(drag.origin) < 2.0 {
return;
}
let action = if let Some(proposal) = proposal.filter(|_| !gone) {
Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement })
} else if (self.permissions)(&drag.panel).floatable
&& (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel)))
{
let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect());
Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] })
} else {
None
};
if let Some(action) = action {
// Validate the proposed gesture against the same complete snapshot. The app
// still applies it via its own command path after body rendering finishes.
let mut candidate = self.layout.clone();
if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() {
self.output.actions.push(action);
self.output.focused = Some(drag.panel);
}
}
return;
}
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview")));
if let Some(proposal) = proposal {
painter.rect(
proposal.preview.shrink(1.0),
0.0,
self.style.accent.gamma_multiply(0.15),
Stroke::new(2.0, self.style.accent),
StrokeKind::Inside,
);
if let Some(marker) = proposal.insertion {
painter.rect_filled(marker, 0.0, self.style.accent);
}
} else if (self.permissions)(&drag.panel).floatable {
let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect());
painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside);
}
}
}
+13 -639
View File
@@ -1,16 +1,11 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Color32, Context, FontId, Id, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, vec2};
use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2};
use crate::{
layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect},
tabs,
};
use super::{Action, DockError, DropTarget, Layout, Node, Permissions, Placement, resolve_drop};
use super::{Action, DockError, Layout, Node, Permissions, render::Renderer};
/// Horizontal room around a tab's label, both when measuring its natural width and when cutting it.
const TAB_PADDING: f32 = 24.0;
pub(super) const TAB_PADDING: f32 = 24.0;
/// Application-supplied chrome. Body renderers retain their own UI style and contents.
#[derive(Clone, Debug)]
@@ -59,7 +54,7 @@ impl DockStyle {
}
}
fn valid(&self) -> bool {
pub(super) fn valid(&self) -> bool {
[self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size].iter().all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
&& self.tab_height > 0.0
&& self.font.size > 0.0
@@ -98,7 +93,7 @@ impl Default for PanelLimits {
}
impl PanelLimits {
fn valid(self) -> bool {
pub(super) fn valid(self) -> bool {
self.min.is_finite()
&& self.max.is_finite()
&& self.min.x >= 0.0
@@ -111,17 +106,17 @@ impl PanelLimits {
}
#[derive(Clone)]
struct Drag<P> {
panel: P,
origin: Pos2,
source: Rect,
grab: Vec2,
pub(super) struct Drag<P> {
pub(super) panel: P,
pub(super) origin: Pos2,
pub(super) source: Rect,
pub(super) grab: Vec2,
}
#[derive(Clone)]
struct GroupDrag<P> {
panel: P,
original: [f32; 4],
pub(super) struct GroupDrag<P> {
pub(super) panel: P,
pub(super) original: [f32; 4],
}
/// Start a docking drag from an application's existing tab/header. Use the same area ID when
@@ -335,624 +330,3 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
renderer.output
}
}
struct Renderer<'a, P> {
id: Id,
style: &'a DockStyle,
label: &'a dyn Fn(&P) -> String,
permissions: &'a dyn Fn(&P) -> Permissions,
limits: &'a dyn Fn(&P) -> PanelLimits,
content: &'a mut dyn DockContent<P>,
activation: Option<P>,
blockers: Vec<Rect>,
layout: &'a Layout<P>,
targets: Vec<DropTarget<P>>,
owned_interactions: Vec<Id>,
output: DockOutput<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
fn register_interaction(&mut self, ctx: &Context, id: Id) {
// A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers,
// floating grips/corners and splits fit comfortably inside this fixed bound.
const CAP: usize = 8 * super::MAX_PANELS;
if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) {
self.owned_interactions.push(id);
}
// Retain prior-frame controls until return, and publish current controls before
// app callbacks. No Context lock spans a callback or a stop_dragging call.
ctx.data_mut(|d| {
let ids = d.get_temp_mut_or_default::<Vec<Id>>(self.id.with("owned-interactions"));
if ids.len() < CAP && !ids.contains(&id) {
ids.push(id);
}
});
}
fn node(&mut self, ui: &mut Ui, node: &Node<P>, rect: Rect, path: &mut Vec<bool>) {
if !super::targets::usable(rect) {
return;
}
match node {
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));
let first_size = wanted.first_size().clamp(lo, hi);
let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return };
path.push(false);
self.node(ui, first, split.first, path);
path.pop();
path.push(true);
self.node(ui, second, split.second, path);
path.pop();
// The divider's inner-edge hit margin must win over child scrollbars, just
// as accordion dividers win over the adjacent panel's content.
let response = ResizeHandle::new(
self.id.with(("split", &*path)),
*axis,
split.divider,
&self.style.resize_label,
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
)
.value(split.first_size(), lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
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 });
}
}
Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false),
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, false);
}
}
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);
let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum();
let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32);
let mut top = rect.top();
let mut dividers = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let share = if entry.open && total_wanted > 0.0 {
(f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32
} else {
0.0
};
let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom())));
let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom())));
self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect);
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());
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel))
});
ui.painter().with_clip_rect(collapse).text(
collapse.center(),
egui::Align2::CENTER_CENTER,
if entry.open { "−" } else { "+" },
self.style.font.clone(),
self.style.text,
);
if response.clicked() {
self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open });
}
let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max);
if entry.open {
self.contents(ui, &entry.panel, body_rect);
}
self.targets.push(DropTarget {
anchor: entry.panel.clone(),
rect: panel_rect,
tab_strip: Some(title_rect),
tabs: vec![(entry.panel.clone(), title_rect)],
allow_splits: true,
accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs,
});
top = panel_rect.bottom();
if entry.open && remaining > 0.0 {
dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect)));
}
}
// Register dividers after the headers so their narrow hit regions win at the
// shared boundary. Closed entries retain their saved sizes and are skipped.
for (index, divider) in dividers {
let Some(first) = entries.get(index) else { continue };
let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue };
let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0));
let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0));
let scale = f64::from(remaining) / total_wanted;
let first_size = (a * scale) as f32;
let pair_size = ((a + b) * scale) as f32;
let body_limits = |panel: &P| {
let mut limits = (self.limits)(panel);
limits.min.y = limits.min.y.max(self.style.min_pane);
limits.max = limits.max.max(limits.min);
limits
};
let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel));
let response = ResizeHandle::new(
self.id.with(("stack-resize", &first.panel)),
SplitAxis::Vertical,
divider,
&self.style.resize_label,
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
)
.value(first_size, lo..=hi)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
if response.delta != 0.0 && scale > 0.0 {
let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale;
let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0));
self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) });
self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) });
}
}
}
}
}
/// Groups stacked top to bottom in equal shares of `rect`.
fn stacked_groups(&mut self, ui: &mut Ui, groups: &[super::Group<P>], rect: Rect, floating: bool) {
let share = rect.height() / groups.len().max(1) as f32;
for (index, group) in groups.iter().enumerate() {
let top = rect.top() + share * index as f32;
let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share));
self.group(ui, &group.panels, group.active, group_rect, floating);
}
}
fn node_limits(&self, node: &Node<P>) -> PanelLimits {
match node {
Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() },
Node::Tabs { panels, .. } => {
let mut limits = PanelLimits::default();
for panel in panels {
let policy = (self.limits)(panel);
limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane));
limits.max = limits.max.min(policy.max);
}
limits.max = limits.max.max(limits.min);
if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) {
limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0);
limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0);
}
limits
}
Node::Stack { entries } => {
let mut limits = PanelLimits::default();
for entry in entries {
let policy = (self.limits)(&entry.panel);
limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane);
limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height;
limits.max.x = limits.max.x.min(policy.max.x);
}
limits.min = limits.min.min(Vec2::splat(1_000_000.0));
limits.max = limits.max.max(limits.min);
limits
}
Node::Split { axis, first, second, .. } => {
let a = self.node_limits(first);
let b = self.node_limits(second);
let limits = match axis {
SplitAxis::Horizontal => PanelLimits {
min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)),
max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)),
},
SplitAxis::Vertical => PanelLimits {
min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap),
max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap),
},
};
PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) }
}
}
}
fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) {
if !super::targets::usable(rect) {
return;
}
ui.painter().rect_filled(rect, 0.0, self.style.background);
let mut child =
ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
self.content.body(&mut child, panel);
if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) {
self.output.focused = Some(panel.clone());
}
}
fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) {
let Some(selected) = panels.get(active) else { return };
let policy = (self.permissions)(selected);
let bare = panels.len() == 1 && policy == Permissions::PROTECTED;
if bare {
self.contents(ui, selected, rect);
self.targets.push(DropTarget {
anchor: selected.clone(),
rect,
tab_strip: None,
tabs: Vec::new(),
allow_splits: !floating,
accepts_tabs: false,
});
return;
}
let mut 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 floating {
let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom()));
let response = ui.interact(grip, self.id.with(("move-group", panels.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 panels.iter().all(|p| (self.permissions)(p).movable)
&& response.drag_started()
&& let Some(first) = panels.first()
{
ui.ctx().data_mut(|d| {
d.insert_temp(
self.id.with("group-drag"),
GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] },
)
});
}
if panels.iter().all(|p| (self.permissions)(p).movable)
&& response.dragged()
&& ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))).is_some()
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
{
let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect());
if let Some(first) = panels.first() {
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);
strip.min.x = grip.right();
}
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 };
if header_width > 0.0 {
let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max);
let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header));
child.set_clip_rect(header.intersect(ui.clip_rect()));
self.content.group_header(&mut child, panels, selected, floating);
}
let widths: Vec<f32> = panels
.iter()
.map(|p| {
self.content
.label(ui, p, &(self.label)(p), &self.style.font, self.style.text)
.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone())
.size()
.x
+ TAB_PADDING
})
.collect();
let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0);
let mut x = strip.left();
let mut targets = Vec::new();
for (index, width) in &fit.shown {
let Some(panel) = panels.get(*index) else { continue };
let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height()));
self.tab(ui, panel, tab, *index == active, rect);
targets.push((panel.clone(), tab));
x += width;
}
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height()));
let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label));
ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text);
egui::Popup::menu(&response).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for index in &fit.overflow {
if let Some(panel) = panels.get(*index)
&& self.menu_label(ui, panel).clicked()
{
self.output.actions.push(Action::Activate { panel: panel.clone() });
self.output.focused = Some(panel.clone());
ui.close();
}
}
});
});
}
// Include hidden tab identities for source-group detection without creating hit areas.
for panel in panels {
if !targets.iter().any(|(p, _)| p == panel) {
targets.push((panel.clone(), Rect::NOTHING));
}
}
self.targets.push(DropTarget {
anchor: selected.clone(),
rect,
tab_strip: Some(strip),
tabs: targets,
allow_splits: !floating,
accepts_tabs: policy.accepts_tabs,
});
self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max));
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
}
fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response {
let logical = (self.label)(panel);
let text = self.content.label(ui, panel, &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));
response
}
fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) {
let policy = (self.permissions)(panel);
let label = (self.label)(panel);
let color = if active { self.style.text } else { self.style.inactive_text };
// A tab squeezed below its natural width cuts its label with '…' inside the padding its
// natural width was measured with, instead of letting it run under its neighbours.
let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
(rect.width() - TAB_PADDING).max(0.0),
self.style.font.clone(),
);
let elided = galley.elided;
let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active)
.sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() })
.focus_stroke(Stroke::new(1.0, self.style.accent))
.show_at(ui, rect, |ui, response| {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background });
painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color);
});
let response = if elided { response.on_hover_text(&label) } else { response };
self.register_interaction(ui.ctx(), response.id);
if response.clicked() {
self.output.actions.push(Action::Activate { panel: panel.clone() });
self.output.focused = Some(panel.clone());
}
if policy.movable && response.drag_started() {
begin_drag(ui.ctx(), self.id, panel.clone(), group);
}
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 response = ui.button(text);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label));
if response.clicked() {
let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] });
ui.close();
}
}
if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() {
let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text);
let response = ui.menu_button(text, |ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for target in self.layout.move_targets(panel, self.permissions) {
if self.menu_label(ui, target).clicked() {
self.output.actions.push(Action::Move {
panel: panel.clone(),
anchor: target.clone(),
placement: Placement::Tab { before: None },
});
ui.close();
}
}
});
});
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label));
}
if policy.closable {
let text = self.content.action_label(ui, &self.style.close_label, &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.close_label));
if response.clicked() {
self.output.actions.push(Action::Close { panel: panel.clone() });
ui.close();
}
}
});
}
fn floating(&mut self, ctx: &Context) {
let screen = ctx.content_rect();
if !super::targets::usable(screen) {
return;
}
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
let mut groups: Vec<_> = self.layout.floating.iter().collect();
groups.sort_by_key(|group| {
let id = group.panels().next().map(|panel| self.id.with(("floating", panel)));
layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX)
});
for group in groups {
let Some(first) = group.panels().next() else { continue };
let [x, y, width, height] = group.rect;
let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 });
let size = vec2(width, height).max(limits.min).min(limits.max);
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| group.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).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());
self.stacked_groups(ui, &group.groups, rect, true);
let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max);
let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe);
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label));
if response.drag_started() {
response.request_focus();
}
let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { 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;
}
});
}
if response.enabled() && delta != Vec2::ZERO {
let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size());
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] });
}
if response.hovered() || response.dragged() || response.has_focus() {
ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent));
}
});
}
}
fn finish_group_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))) else { return };
if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
return;
}
let (escape, focused, down, released, pointer) =
ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos()));
// A button release outside the window may never reach egui.
let has_pointer = ctx.input(|i| i.pointer.has_pointer());
// Touch-up also removes the pointer, but still completes a drop.
let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. })));
if escape || !focused || !down || !has_pointer {
cancel_drag::<P>(ctx, self.id);
if escape || !focused {
self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original });
} else if released
&& (has_pointer || touch_ended)
&& let Some(pointer) = pointer.filter(|p| p.is_finite())
&& !self.blockers.iter().any(|r| r.contains(pointer))
{
self.output.group_drop = Some((drag.panel, pointer));
}
} else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) {
self.output.group_drag = Some(drag.panel);
}
}
fn finish_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) else { return };
let (pointer, down, released, focused, gone) =
ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer()));
// Leaving the window is the drop: on X11 and Windows the release outside never reaches
// egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the
// last position this frame, and the panel floats there rather than docking at the edge.
let released = released || gone;
let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape));
if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) {
cancel_drag::<P>(ctx, self.id);
return;
}
let Some(pointer) = pointer.filter(|p| p.is_finite()) else {
if released {
cancel_drag::<P>(ctx, self.id);
}
return;
};
if self.blockers.iter().any(|rect| rect.contains(pointer)) {
if released {
cancel_drag::<P>(ctx, self.id);
}
return;
}
let proposal = resolve_drop(&drag.panel, pointer, &self.targets);
if released {
cancel_drag::<P>(ctx, self.id);
if pointer.distance(drag.origin) < 2.0 {
return;
}
let action = if let Some(proposal) = proposal.filter(|_| !gone) {
Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement })
} else if (self.permissions)(&drag.panel).floatable
&& (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel)))
{
let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect());
Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] })
} else {
None
};
if let Some(action) = action {
// Validate the proposed gesture against the same complete snapshot. The app
// still applies it via its own command path after body rendering finishes.
let mut candidate = self.layout.clone();
if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() {
self.output.actions.push(action);
self.output.focused = Some(drag.panel);
}
}
return;
}
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview")));
if let Some(proposal) = proposal {
painter.rect(
proposal.preview.shrink(1.0),
0.0,
self.style.accent.gamma_multiply(0.15),
Stroke::new(2.0, self.style.accent),
StrokeKind::Inside,
);
if let Some(marker) = proposal.insertion {
painter.rect_filled(marker, 0.0, self.style.accent);
}
} else if (self.permissions)(&drag.panel).floatable {
let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect());
painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside);
}
}
}
fn clamp_float(rect: Rect, screen: Rect) -> Rect {
if !super::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)
}
fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) {
let (a_min, a_max, b_min, b_max) = match axis {
SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x),
SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y),
};
let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 };
let lo = (a_min * scale).min(available);
let hi = (available - b_min * scale).max(lo).min(available);
let constrained_lo = lo.max(available - b_max);
let constrained_hi = hi.min(a_max);
if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) }
}