mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 23:59:16 +00:00
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:
1 parent
69a1adf699
commit
87ddebd73a
6 files changed
+673
-639
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
Reference in new issue
Block a user