From 87ddebd73ab8e214c4d2591f74ef256d17513fbc Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:41:18 +0000 Subject: [PATCH] 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. --- crates/ui/src/docking/mod.rs | 3 + crates/ui/src/docking/render/floating.rs | 77 +++ crates/ui/src/docking/render/group.rs | 214 ++++++++ crates/ui/src/docking/render/mod.rs | 260 +++++++++ crates/ui/src/docking/render/preview.rs | 106 ++++ crates/ui/src/docking/surface.rs | 652 +---------------------- 6 files changed, 673 insertions(+), 639 deletions(-) create mode 100644 crates/ui/src/docking/render/floating.rs create mode 100644 crates/ui/src/docking/render/group.rs create mode 100644 crates/ui/src/docking/render/mod.rs create mode 100644 crates/ui/src/docking/render/preview.rs diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 304e46d2..b47dd39d 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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)] diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs new file mode 100644 index 00000000..64ca2da4 --- /dev/null +++ b/crates/ui/src/docking/render/floating.rs @@ -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 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::>()); + 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)); + } + }); + } + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs new file mode 100644 index 00000000..850a22aa --- /dev/null +++ b/crates/ui/src/docking/render/group.rs @@ -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 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

], 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::>(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 = 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(); + } + } + }); + } +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs new file mode 100644 index 00000000..c5a031f9 --- /dev/null +++ b/crates/ui/src/docking/render/mod.rs @@ -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

, + pub(super) activation: Option

, + pub(super) blockers: Vec, + pub(super) layout: &'a Layout

, + pub(super) targets: Vec>, + pub(super) owned_interactions: Vec, + pub(super) output: DockOutput

, +} + +impl 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::>(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

, rect: Rect, path: &mut Vec) { + 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

) -> 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) } +} diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs new file mode 100644 index 00000000..e4e427ed --- /dev/null +++ b/crates/ui/src/docking/render/preview.rs @@ -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 Renderer<'_, P> { + pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { + let Some(drag) = ctx.data(|d| d.get_temp::>(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::>(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::

(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::>(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::

(ctx, self.id); + return; + } + let Some(pointer) = pointer.filter(|p| p.is_finite()) else { + if released { + cancel_drag::

(ctx, self.id); + } + return; + }; + if self.blockers.iter().any(|rect| rect.contains(pointer)) { + if released { + cancel_drag::

(ctx, self.id); + } + return; + } + let proposal = resolve_drop(&drag.panel, pointer, &self.targets); + if released { + cancel_drag::

(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); + } + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 545bc432..3d034faf 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -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

{ - panel: P, - origin: Pos2, - source: Rect, - grab: Vec2, +pub(super) struct Drag

{ + pub(super) panel: P, + pub(super) origin: Pos2, + pub(super) source: Rect, + pub(super) grab: Vec2, } #[derive(Clone)] -struct GroupDrag

{ - panel: P, - original: [f32; 4], +pub(super) struct GroupDrag

{ + 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 DockArea

{ 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

, - activation: Option

, - blockers: Vec, - layout: &'a Layout

, - targets: Vec>, - owned_interactions: Vec, - output: DockOutput

, -} - -impl 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::>(self.id.with("owned-interactions")); - if ids.len() < CAP && !ids.contains(&id) { - ids.push(id); - } - }); - } - - fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { - 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

], 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

) -> 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::>(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 = 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::>()); - 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::>(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::>(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::

(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::>(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::

(ctx, self.id); - return; - } - let Some(pointer) = pointer.filter(|p| p.is_finite()) else { - if released { - cancel_drag::

(ctx, self.id); - } - return; - }; - if self.blockers.iter().any(|rect| rect.contains(pointer)) { - if released { - cancel_drag::

(ctx, self.id); - } - return; - } - let proposal = resolve_drop(&drag.panel, pointer, &self.targets); - if released { - cancel_drag::

(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) } -}