mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
docking: merge floating windows, frame profile and native windows
Merge the docking-t4 track (floating title bars and stacks, frame profile, native windows, public chrome) into the dock profile track. Integration: - DockStyle keeps two edge bands: `edge_band` (8) is the outer edge band that splits at the root; `new_dock_band` (12) is the band along the docked area's left and right edges that makes a new dock. One `seam_band` serves dock seams and floating stack seams. - New-dock targets are offered in the dock profile rather than only in layouts that already have a dock. - Window title bar drags take the dock targets: a column edge makes a new column, a seam or group band stacks the window's groups there, the new-dock band makes a dock. They still never split a frame or join a body centre. Tab drags check dock targets before floating stack and outer edge targets. - One group_action maps column, window and group gestures to MoveColumn, MoveWindow and MoveGroup. - The unused stacked_groups renderer is gone; docks and floating windows each draw their own stacks.
This commit is contained in:
commit
d37d9aeff3
17 files changed
+1852
-282
No files matched your search
@@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"], optional = true }
|
||||
|
||||
[features]
|
||||
serde = ["dep:serde"]
|
||||
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
|
||||
native-windows = []
|
||||
|
||||
[dev-dependencies]
|
||||
egui = { workspace = true, features = ["default_fonts"] }
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with a
|
||||
//! grip, an optional ▾ menu button and ×. [`super::DockArea`] draws its floating windows with
|
||||
//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes
|
||||
//! a layout: the caller turns the responses into its own commands.
|
||||
|
||||
use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2};
|
||||
|
||||
use super::{DockStyle, PanelLimits, resize::AnchoredResize};
|
||||
|
||||
/// Height of a floating window's title bar.
|
||||
pub const TITLE_HEIGHT: f32 = 14.0;
|
||||
|
||||
/// Names of the resize handles, appended to the frame id: `id.with(name)`.
|
||||
pub const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"];
|
||||
|
||||
/// Colours and font of the frame and title bar.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChromeStyle {
|
||||
/// Painted under the contents, so the window stays opaque when the contents are not.
|
||||
pub background: Color32,
|
||||
pub stroke: Stroke,
|
||||
pub title_background: Color32,
|
||||
pub text: Color32,
|
||||
pub weak_text: Color32,
|
||||
pub accent: Color32,
|
||||
pub font: FontId,
|
||||
}
|
||||
|
||||
impl ChromeStyle {
|
||||
pub fn from_ui(ui: &Ui) -> Self {
|
||||
Self::from_dock(&DockStyle::from_ui(ui))
|
||||
}
|
||||
|
||||
pub fn from_dock(style: &DockStyle) -> Self {
|
||||
Self {
|
||||
background: style.floating_background,
|
||||
stroke: style.floating_stroke,
|
||||
title_background: style.tab_background,
|
||||
text: style.text,
|
||||
weak_text: style.inactive_text,
|
||||
accent: style.accent,
|
||||
font: style.font.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize handle hit rects and the edges they move: both sides, the bottom and both bottom
|
||||
/// corners. The top edge belongs to the title bar.
|
||||
fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] {
|
||||
[
|
||||
("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)),
|
||||
(
|
||||
"resize-left",
|
||||
Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)),
|
||||
vec2(-1.0, 0.0),
|
||||
),
|
||||
(
|
||||
"resize-right",
|
||||
Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)),
|
||||
vec2(1.0, 0.0),
|
||||
),
|
||||
(
|
||||
"resize-bottom",
|
||||
Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())),
|
||||
vec2(0.0, 1.0),
|
||||
),
|
||||
(
|
||||
"resize-bottom-left",
|
||||
Rect::from_min_max(
|
||||
pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())),
|
||||
pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()),
|
||||
),
|
||||
vec2(-1.0, 1.0),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// An opaque in-window frame on its own `Order::Middle` layer, `id`, at `rect`.
|
||||
pub struct FloatingFrame<'a> {
|
||||
id: Id,
|
||||
rect: Rect,
|
||||
bounds: Option<Rect>,
|
||||
limits: PanelLimits,
|
||||
resizable: bool,
|
||||
resize_label: &'a str,
|
||||
}
|
||||
|
||||
/// What a [`FloatingFrame`] did this frame.
|
||||
pub struct FrameResponse<R> {
|
||||
pub inner: R,
|
||||
/// Where the frame was drawn, inside its bounds.
|
||||
pub rect: Rect,
|
||||
pub layer: LayerId,
|
||||
/// The rect a resize handle asks for: pointer drags anchored at the opposite edges, or
|
||||
/// arrow keys on a focused handle (8 pt per press). Escape and focus loss end a resize.
|
||||
pub resized: Option<Rect>,
|
||||
/// The handle whose pointer resize started this frame.
|
||||
pub resize_started: Option<Id>,
|
||||
/// Every handle's response, for focus and gesture bookkeeping.
|
||||
pub handles: Vec<Response>,
|
||||
}
|
||||
|
||||
impl<'a> FloatingFrame<'a> {
|
||||
pub fn new(id: Id, rect: Rect) -> Self {
|
||||
Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window" }
|
||||
}
|
||||
|
||||
/// The frame stays inside `bounds`; the default is the viewport's content rect.
|
||||
pub fn bounds(mut self, bounds: Rect) -> Self {
|
||||
self.bounds = Some(bounds);
|
||||
self
|
||||
}
|
||||
|
||||
/// Size limits of a resize. The frame itself is drawn at the given rect.
|
||||
pub fn limits(mut self, limits: PanelLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resizable(mut self, resizable: bool) -> Self {
|
||||
self.resizable = resizable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Accessibility label of the resize handles.
|
||||
pub fn resize_label(mut self, label: &'a str) -> Self {
|
||||
self.resize_label = label;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn layer(id: Id) -> LayerId {
|
||||
LayerId::new(Order::Middle, id)
|
||||
}
|
||||
|
||||
/// Draw the frame, then `add_contents` with the frame's rect, then the outline and handles.
|
||||
pub fn show<R>(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse<R> {
|
||||
let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect());
|
||||
let rect = clamp(self.rect, bounds);
|
||||
let area = egui::Area::new(self.id)
|
||||
.fade_in(false)
|
||||
.order(Order::Middle)
|
||||
.fixed_pos(rect.min)
|
||||
.default_size(rect.size())
|
||||
.movable(false)
|
||||
.constrain_to(bounds);
|
||||
let shown = area.show(ctx, |ui| {
|
||||
// Drawn at `rect` itself, also in egui's first (sizing) pass of a new area.
|
||||
ui.set_min_size(rect.size());
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
ui.painter().rect_filled(rect, 0.0, style.background);
|
||||
let inner = add_contents(ui, rect);
|
||||
ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside);
|
||||
let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) };
|
||||
FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles }
|
||||
});
|
||||
shown.inner
|
||||
}
|
||||
|
||||
fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option<Rect>, Option<Id>, Vec<Response>) {
|
||||
let mut resized = None;
|
||||
let mut started = None;
|
||||
let mut responses = Vec::new();
|
||||
let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits };
|
||||
let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused);
|
||||
for (name, handle, direction) in handles(rect) {
|
||||
if !super::targets::usable(handle) {
|
||||
continue;
|
||||
}
|
||||
let cursor = if direction.x == 0.0 {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else if direction.y == 0.0 {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
} else if direction.x == direction.y {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
};
|
||||
let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.resize_label));
|
||||
let anchor_id = response.id.with("pointer-anchor");
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
if let Some(origin) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(anchor) = AnchoredResize::new(rect, origin, direction)
|
||||
{
|
||||
ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor));
|
||||
started = Some(response.id);
|
||||
}
|
||||
}
|
||||
let mut delta = 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| {
|
||||
for (key, step) in [
|
||||
(Key::ArrowLeft, vec2(-8.0, 0.0)),
|
||||
(Key::ArrowRight, vec2(8.0, 0.0)),
|
||||
(Key::ArrowUp, vec2(0.0, -8.0)),
|
||||
(Key::ArrowDown, vec2(0.0, 8.0)),
|
||||
] {
|
||||
if input.consume_key(egui::Modifiers::NONE, key) {
|
||||
delta += step;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let next = if response.dragged() || response.drag_stopped() {
|
||||
ui.ctx()
|
||||
.data(|data| data.get_temp::<AnchoredResize>(anchor_id))
|
||||
.and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, bounds, limits)))
|
||||
} else if delta != Vec2::ZERO {
|
||||
AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, bounds, limits))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if response.enabled()
|
||||
&& !cancelled
|
||||
&& let Some(next) = next.filter(|next| *next != rect)
|
||||
{
|
||||
resized = Some(next);
|
||||
}
|
||||
if response.drag_stopped() {
|
||||
response.surrender_focus();
|
||||
}
|
||||
if cancelled || !ui.input(|input| input.pointer.primary_down()) {
|
||||
ui.ctx().data_mut(|data| data.remove::<AnchoredResize>(anchor_id));
|
||||
}
|
||||
if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) {
|
||||
ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent));
|
||||
}
|
||||
responses.push(response);
|
||||
}
|
||||
(resized, started, responses)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `rect` inside `bounds`, shrinking it when it is larger.
|
||||
pub fn clamp(rect: Rect, bounds: Rect) -> Rect {
|
||||
if !super::targets::usable(bounds) {
|
||||
return rect;
|
||||
}
|
||||
let size = rect.size().max(vec2(1.0, 1.0)).min(bounds.size());
|
||||
let min = rect.min.max(bounds.min).min(bounds.max - size);
|
||||
Rect::from_min_size(min, size)
|
||||
}
|
||||
|
||||
/// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt
|
||||
/// in from the right edge. The bar senses clicks and drags; double-clicks are the caller's.
|
||||
pub struct TitleBar<'a> {
|
||||
id: Id,
|
||||
move_label: &'a str,
|
||||
title: Option<WidgetText>,
|
||||
menu: Option<&'a str>,
|
||||
close: Option<&'a str>,
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
/// The parts of a [`TitleBar`] that were shown.
|
||||
pub struct TitleResponse {
|
||||
/// Drag to move; double-click is the caller's (docking minimizes every group).
|
||||
pub bar: Response,
|
||||
pub menu: Option<Response>,
|
||||
pub close: Option<Response>,
|
||||
}
|
||||
|
||||
impl<'a> TitleBar<'a> {
|
||||
/// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`.
|
||||
/// `move_label` names the bar for accessibility.
|
||||
pub fn new(id: Id, move_label: &'a str) -> Self {
|
||||
Self { id, move_label, title: None, menu: None, close: None, movable: true }
|
||||
}
|
||||
|
||||
/// Text at the left instead of the grip lines.
|
||||
pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Show a ▾ button with this accessibility label.
|
||||
pub fn menu(mut self, label: &'a str) -> Self {
|
||||
self.menu = Some(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Show a × button with this accessibility label.
|
||||
pub fn close(mut self, label: &'a str) -> Self {
|
||||
self.close = Some(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the bar shows the grab cursor. It senses drags either way.
|
||||
pub fn movable(mut self, movable: bool) -> Self {
|
||||
self.movable = movable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse {
|
||||
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
painter.rect_filled(rect, 0.0, style.title_background);
|
||||
let side = rect.height();
|
||||
let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)));
|
||||
let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0);
|
||||
let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom())));
|
||||
let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left());
|
||||
let bar_rect = Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom()));
|
||||
let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag());
|
||||
let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar };
|
||||
bar.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.move_label));
|
||||
if let Some(title) = self.title {
|
||||
let galley = title.into_galley(ui, Some(egui::TextWrapMode::Truncate), (bar_rect.width() - 12.0).max(0.0), style.font.clone());
|
||||
painter.galley(pos2(rect.left() + 6.0, rect.center().y - galley.size().y * 0.5), galley, style.text);
|
||||
} else {
|
||||
let grip = Stroke::new(0.6, style.weak_text);
|
||||
for k in 0..2 {
|
||||
let y = rect.center().y - 1.5 + k as f32 * 3.0;
|
||||
painter.line_segment([pos2(rect.center().x - 9.0, y), pos2(rect.center().x + 9.0, y)], grip);
|
||||
}
|
||||
}
|
||||
if bar.has_focus() {
|
||||
painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside);
|
||||
}
|
||||
let button = |ui: &mut Ui, rect: Rect, name: &str, label: &str| {
|
||||
let response = ui.interact(rect, self.id.with(name), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
let color = if response.hovered() || response.has_focus() { style.text } else { style.weak_text };
|
||||
(response, Stroke::new(1.2, color))
|
||||
};
|
||||
let menu = menu_rect.zip(self.menu).map(|(menu_rect, label)| {
|
||||
let (response, stroke) = button(ui, menu_rect, "menu", label);
|
||||
let c = menu_rect.center();
|
||||
painter.add(egui::Shape::convex_polygon(vec![c + vec2(-3.5, -1.5), c + vec2(3.5, -1.5), c + vec2(0.0, 2.5)], stroke.color, Stroke::NONE));
|
||||
response
|
||||
});
|
||||
let close = close_rect.zip(self.close).map(|(close_rect, label)| {
|
||||
let (response, stroke) = button(ui, close_rect, "close", label);
|
||||
let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0)));
|
||||
painter.line_segment([r.left_top(), r.right_bottom()], stroke);
|
||||
painter.line_segment([r.right_top(), r.left_bottom()], stroke);
|
||||
response
|
||||
});
|
||||
TitleResponse { bar, menu, close }
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::hash::Hash;
|
||||
|
||||
use super::model::{DockError, Floating, Layout, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width};
|
||||
use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width};
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
|
||||
/// Width of a new column when nothing better is known.
|
||||
@@ -242,6 +242,25 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The node an edge split wraps: the root, or the part between the docks.
|
||||
pub(in crate::docking) fn edge_path(&self) -> Vec<bool> {
|
||||
let mut path = Vec::new();
|
||||
let mut node = self.root.as_ref();
|
||||
while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node {
|
||||
if path.len() > MAX_DEPTH {
|
||||
break;
|
||||
}
|
||||
let second_side = match (&**first, &**second) {
|
||||
(Node::Dock { .. }, _) => true,
|
||||
(_, Node::Dock { .. }) => false,
|
||||
_ => break,
|
||||
};
|
||||
path.push(second_side);
|
||||
node = Some(if second_side { second } else { first });
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec<Column<P>>> {
|
||||
match self.root.as_mut().and_then(|root| node_at_mut(root, path)) {
|
||||
Some(Node::Dock { columns, .. }) => Some(columns),
|
||||
@@ -494,6 +513,23 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
groups.splice(index..index, moving.groups);
|
||||
Ok(())
|
||||
}
|
||||
Placement::Edge(zone) => {
|
||||
if zone == Zone::Center || matches!(place, Place::Floating { .. }) {
|
||||
return Err(DockError::InvalidPlacement);
|
||||
}
|
||||
let path = self.edge_path();
|
||||
let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?;
|
||||
if matches!(node, Node::Dock { .. }) {
|
||||
return Err(DockError::InvalidPlacement);
|
||||
}
|
||||
let (panels, active) = flatten(moving.groups);
|
||||
let new = Node::Tabs { panels, active };
|
||||
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
|
||||
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
|
||||
let (first, second, size) = if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) };
|
||||
*node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) };
|
||||
Ok(())
|
||||
}
|
||||
Placement::Column { after } => {
|
||||
let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) };
|
||||
let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?;
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Floating windows (title bar, stacks, close), the frame profile (outer edge, Maximize,
|
||||
//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome.
|
||||
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, Vec2, pos2, vec2};
|
||||
|
||||
const AREA: &str = "floating-area";
|
||||
|
||||
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
|
||||
Node::Tabs { panels: panels.to_vec(), active }
|
||||
}
|
||||
|
||||
/// `[1] | [2, 3]` with panel 3 selected.
|
||||
fn frames() -> Layout<u32> {
|
||||
Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(300.0),
|
||||
first: Box::new(tabs(&[1], 0)),
|
||||
second: Box::new(tabs(&[2, 3], 1)),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A start dock (`[2, 3]` above `[4]`) beside protected canvas 0; window W1 stacks `[5]` above
|
||||
/// `[6, 7]`; window W2 holds `[8]`.
|
||||
fn workspace() -> Layout<u32> {
|
||||
Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(240.0),
|
||||
first: Box::new(Node::Dock {
|
||||
side: Side::Start,
|
||||
columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)],
|
||||
}),
|
||||
second: Box::new(tabs(&[0], 0)),
|
||||
}),
|
||||
floating: vec![
|
||||
Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [300.0, 80.0, 240.0, 300.0], native: None },
|
||||
Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Content {
|
||||
native: bool,
|
||||
keep: Vec<u32>,
|
||||
}
|
||||
|
||||
impl DockContent<u32> for Content {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Body");
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
fn allow_native(&self, _: &u32) -> bool {
|
||||
self.native
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Hide {
|
||||
None,
|
||||
All,
|
||||
Keep,
|
||||
}
|
||||
|
||||
struct Driver {
|
||||
ctx: Context,
|
||||
layout: Layout<u32>,
|
||||
content: Content,
|
||||
profile: Profile,
|
||||
maximized: Option<u32>,
|
||||
hide: Hide,
|
||||
modifiers: Modifiers,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(layout: Layout<u32>, profile: Profile) -> Self {
|
||||
let mut driver = Self {
|
||||
ctx: Context::default(),
|
||||
layout,
|
||||
content: Content::default(),
|
||||
profile,
|
||||
maximized: None,
|
||||
hide: Hide::None,
|
||||
modifiers: Modifiers::NONE,
|
||||
time: 0.0,
|
||||
};
|
||||
driver.step(vec![]);
|
||||
driver.step(vec![]);
|
||||
driver
|
||||
}
|
||||
|
||||
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
|
||||
let mut output = DockOutput::default();
|
||||
events.insert(0, Event::ModifiersChanged(self.modifiers));
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
|
||||
events,
|
||||
focused: true,
|
||||
time: Some(self.time),
|
||||
..Default::default()
|
||||
};
|
||||
let (layout, content, profile, maximized, hide) = (&self.layout, &mut self.content, self.profile, self.maximized, self.hide);
|
||||
let keep: Vec<u32> = content.keep.clone();
|
||||
let keep = move |p: &u32| keep.contains(p);
|
||||
let full = self.ctx.run_ui(input, |ui| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.profile = profile;
|
||||
let hide = match hide {
|
||||
Hide::None => HidePanels::None,
|
||||
Hide::All => HidePanels::All,
|
||||
Hide::Keep => HidePanels::Except(&keep),
|
||||
};
|
||||
output = DockArea::new(Id::new(AREA)).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content);
|
||||
});
|
||||
let mut full = full;
|
||||
full.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
|
||||
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
|
||||
let output = self.frame(events);
|
||||
assert!(output.error.is_none(), "{output:?}");
|
||||
for action in &output.actions {
|
||||
self.layout.apply_with_permissions(action.clone(), |p| self.content.permissions(p)).unwrap();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn response(&self, id: Id) -> Option<Rect> {
|
||||
self.ctx.read_response(id).map(|r| r.rect)
|
||||
}
|
||||
|
||||
fn title(&self, first: u32) -> Rect {
|
||||
self.response(Id::new(AREA).with(("move-group", Some(&first)))).unwrap()
|
||||
}
|
||||
|
||||
fn tab(&self, panel: u32) -> Option<Rect> {
|
||||
self.response(Id::new(AREA).with(("tab", &panel)))
|
||||
}
|
||||
|
||||
fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(from), button(from, true)]);
|
||||
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
|
||||
self.step(vec![Event::PointerMoved(to)])
|
||||
}
|
||||
|
||||
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
|
||||
self.hold(from, to);
|
||||
self.step(vec![button(to, false)])
|
||||
}
|
||||
|
||||
fn click(&mut self, at: egui::Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(at), button(at, true)]);
|
||||
self.step(vec![button(at, false)])
|
||||
}
|
||||
|
||||
fn wait(&mut self) {
|
||||
for _ in 0..45 {
|
||||
self.step(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn done(mut full: egui::FullOutput) {
|
||||
full.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn button(pos: egui::Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn window_of(layout: &Layout<u32>, panel: u32) -> Option<&Floating<u32>> {
|
||||
layout.floating.iter().find(|w| w.contains(&panel))
|
||||
}
|
||||
|
||||
fn groups_of(window: &Floating<u32>) -> Vec<Vec<u32>> {
|
||||
window.groups.iter().map(|g| g.panels.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() {
|
||||
// The window follows the pointer with every group; Escape puts it back.
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let title = d.title(5).center();
|
||||
d.hold(title, title + vec2(40.0, 30.0));
|
||||
assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]);
|
||||
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![5], vec![6, 7]]);
|
||||
d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
d.step(vec![button(title + vec2(40.0, 30.0), false)]);
|
||||
assert_eq!(d.layout, workspace());
|
||||
|
||||
// Released over another window's lower band: its groups stack below that window's.
|
||||
let out = d.drag(title, pos2(660.0, 480.0));
|
||||
assert!(
|
||||
out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true } } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert_eq!(d.layout.floating.len(), 1);
|
||||
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]);
|
||||
|
||||
// Released over a docked group's strip: every panel joins it as tabs.
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let title = d.title(5).center();
|
||||
let strip = d.tab(4).unwrap().center();
|
||||
let out = d.drag(title, strip);
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 4, .. } })), "{:?}", out.actions);
|
||||
assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 7).is_none());
|
||||
assert_eq!(d.layout.place(&4), d.layout.place(&6));
|
||||
|
||||
// Released over the canvas: the window stays where it was moved and hosts get the release.
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let title = d.title(5).center();
|
||||
let out = d.drag(title, pos2(500.0, 300.0));
|
||||
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions);
|
||||
assert_eq!(out.group_drop, Some((5, pos2(500.0, 300.0))));
|
||||
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_stack_seams_and_bands_insert_groups() {
|
||||
// A group dragged by its bar to another window's top band stacks above that group.
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center();
|
||||
let out = d.drag(bar, pos2(660.0, 360.0));
|
||||
assert!(
|
||||
out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false } } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]);
|
||||
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]);
|
||||
|
||||
// A tab dropped on the seam between two stacked groups becomes a group between them.
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let tab = d.tab(8).unwrap().center();
|
||||
let seam = pos2(420.0, 237.0);
|
||||
let out = d.drag(tab, seam);
|
||||
assert!(out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), "{:?}", out.actions);
|
||||
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]);
|
||||
|
||||
// The divider between two open groups resizes both.
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let divider = d.response(Id::new(AREA).with(("floating-divider", Some(&5u32)))).unwrap().center();
|
||||
d.drag(divider, divider + vec2(0.0, 40.0));
|
||||
let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect();
|
||||
assert!((heights[0] - 181.5).abs() < 2.0 && (heights[1] - 101.5).abs() < 2.0, "{heights:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() {
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
let title = d.title(5).center();
|
||||
d.click(title);
|
||||
d.click(title);
|
||||
assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| g.minimized));
|
||||
d.wait();
|
||||
d.click(title);
|
||||
d.click(title);
|
||||
assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| !g.minimized));
|
||||
|
||||
let close = d.response(Id::new(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap();
|
||||
assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge");
|
||||
let before = d.layout.clone();
|
||||
let out = d.click(close.center());
|
||||
assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]);
|
||||
assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7]));
|
||||
assert!(window_of(&d.layout, 6).is_none() && d.layout.contains(&8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_floating_window_reopens_each_panel_at_its_last_rect() {
|
||||
let mut layout = workspace();
|
||||
let locations: Vec<_> = [5, 6, 7].map(|p| (p, layout.location(&p).unwrap())).into();
|
||||
layout.apply(Action::CloseWindow { panel: 6 }).unwrap();
|
||||
for (panel, location) in locations {
|
||||
layout.restore(panel, &location).unwrap();
|
||||
assert_eq!(window_of(&layout, panel).unwrap().rect, [300.0, 80.0, 240.0, 300.0], "{panel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() {
|
||||
let mut d = Driver::new(frames(), Profile::Frame);
|
||||
let tab = d.tab(3).unwrap().center();
|
||||
let other = d.tab(1).unwrap().center();
|
||||
d.modifiers = Modifiers::COMMAND;
|
||||
d.hold(tab, other);
|
||||
d.modifiers = Modifiers::NONE;
|
||||
let out = d.step(vec![button(other, false)]);
|
||||
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
|
||||
assert!(window_of(&d.layout, 3).is_some());
|
||||
|
||||
// Without the modifier the same drag joins the other frame.
|
||||
let mut d = Driver::new(frames(), Profile::Frame);
|
||||
let out = d.drag(tab, other);
|
||||
assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_edge_band_splits_at_the_root() {
|
||||
// Frame profile: all four edges.
|
||||
let mut d = Driver::new(frames(), Profile::Frame);
|
||||
let tab = d.tab(3).unwrap().center();
|
||||
let out = d.drag(tab, pos2(3.0, 300.0));
|
||||
assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions);
|
||||
let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
|
||||
assert_eq!(**first, tabs(&[3], 0));
|
||||
|
||||
// Dock profile: the bottom edge of the part between the docks, for a Timeline.
|
||||
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock);
|
||||
let tab = d.tab(2).unwrap().center();
|
||||
let out = d.drag(tab, pos2(500.0, 597.0));
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions);
|
||||
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
|
||||
assert!(matches!(**first, Node::Dock { .. }));
|
||||
assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0)));
|
||||
|
||||
// Its left and right edges are the docks' (new columns and docks), not root splits.
|
||||
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock);
|
||||
let tab = d.tab(4).unwrap().center();
|
||||
let out = d.drag(tab, pos2(246.0, 300.0));
|
||||
assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() {
|
||||
let mut layout = frames();
|
||||
layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0]));
|
||||
let mut d = Driver::new(layout.clone(), Profile::Frame);
|
||||
let window_title = d.title(5).center();
|
||||
d.maximized = Some(2);
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.tab(2).unwrap().left(), 0.0, "the maximized frame starts at the area's edge");
|
||||
assert!(d.tab(1).is_none());
|
||||
assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none());
|
||||
let out = d.drag(window_title, window_title + vec2(30.0, 30.0));
|
||||
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions);
|
||||
|
||||
// A floating panel does not maximize.
|
||||
let mut d = Driver::new(layout, Profile::Frame);
|
||||
d.maximized = Some(5);
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert!(d.tab(1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_other_panels_keeps_only_the_chosen_tab() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
type State = (Content, Vec<Action<u32>>, Vec<DockEvent<u32>>);
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, (content, actions, events): &mut State| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.profile = Profile::Frame;
|
||||
let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content);
|
||||
actions.extend(out.actions);
|
||||
events.extend(out.events);
|
||||
},
|
||||
(Content::default(), Vec::new(), Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let at = h.ctx.read_response(Id::new(AREA).with(("tab", &3u32))).unwrap().rect.center();
|
||||
let open_menu = |h: &mut Harness<'_, State>| {
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
};
|
||||
open_menu(&mut h);
|
||||
for label in ["Undock Panel", "Close Panel Group", "Undock Panel Group", "Close Other Panels in Group", "Maximize Frame"] {
|
||||
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
|
||||
}
|
||||
assert_eq!(h.query_all_by_label("Minimize").count(), 0);
|
||||
h.get_by_label("Close Other Panels in Group").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().1, vec![Action::CloseOthers { panel: 3 }]);
|
||||
let mut layout = frames();
|
||||
layout.apply(Action::CloseOthers { panel: 3 }).unwrap();
|
||||
assert!(layout.contains(&3) && !layout.contains(&2));
|
||||
|
||||
open_menu(&mut h);
|
||||
h.get_by_label("Maximize Frame").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2, vec![DockEvent::ToggleMaximize { panel: 3 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_panels_keeps_protected_panels_and_the_except_set() {
|
||||
let mut d = Driver::new(workspace(), Profile::Dock);
|
||||
d.hide = Hide::All;
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
for panel in [2, 3, 4, 5, 6, 8] {
|
||||
assert!(d.tab(panel).is_none(), "{panel}");
|
||||
}
|
||||
assert!(d.layout == workspace(), "hiding leaves the layout unchanged");
|
||||
|
||||
d.hide = Hide::Keep;
|
||||
d.content.keep = vec![4, 6];
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.tab(4).map(|r| r.left()), Some(0.0));
|
||||
assert!(d.tab(6).is_some());
|
||||
for panel in [2, 3, 5, 7, 8] {
|
||||
assert!(d.tab(panel).is_none(), "{panel}");
|
||||
}
|
||||
// A window keeps its rect with its hidden groups left out.
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.title(6).min, pos2(300.0, 80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_window_falls_back_in_window_when_viewports_are_embedded() {
|
||||
let mut layout = workspace();
|
||||
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
|
||||
let mut d = Driver::new(layout.clone(), Profile::Dock);
|
||||
d.content.native = true;
|
||||
assert!(d.ctx.embed_viewports());
|
||||
let out = d.step(vec![]);
|
||||
assert!(out.actions.is_empty(), "{out:?}");
|
||||
assert_eq!(d.title(5).min, pos2(300.0, 80.0));
|
||||
assert_eq!(d.layout, layout);
|
||||
}
|
||||
|
||||
#[cfg(feature = "native-windows")]
|
||||
#[test]
|
||||
fn native_window_close_returns_the_group_to_its_in_window_rect() {
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
let close = Rc::new(Cell::new(false));
|
||||
let requested = close.clone();
|
||||
// A backend with real viewports: the child reports its place and, when asked, a close.
|
||||
Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
|
||||
let mut info = egui::ViewportInfo {
|
||||
outer_rect: Some(Rect::from_min_size(pos2(1210.0, 905.0), vec2(250.0, 330.0))),
|
||||
inner_rect: Some(Rect::from_min_size(pos2(1210.0, 935.0), vec2(250.0, 300.0))),
|
||||
..Default::default()
|
||||
};
|
||||
if requested.get() {
|
||||
info.events.push(egui::ViewportEvent::Close);
|
||||
}
|
||||
let input = RawInput {
|
||||
viewport_id: viewport.ids.this,
|
||||
viewports: std::iter::once((viewport.ids.this, info)).collect(),
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(250.0, 300.0))),
|
||||
..Default::default()
|
||||
};
|
||||
done(ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)));
|
||||
});
|
||||
let mut layout = workspace();
|
||||
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
|
||||
let mut d = Driver::new(Layout::default(), Profile::Dock);
|
||||
d.ctx.set_embed_viewports(false);
|
||||
d.content.native = true;
|
||||
d.layout = layout;
|
||||
let out = d.step(vec![]);
|
||||
assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window");
|
||||
assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 905.0, 250.0, 300.0]) }), "{:?}", out.actions);
|
||||
|
||||
close.set(true);
|
||||
let out = d.step(vec![]);
|
||||
assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: None }), "{:?}", out.actions);
|
||||
assert_eq!(d.layout.floating[0].native, None);
|
||||
assert_eq!(d.layout.floating[0].rect, [300.0, 80.0, 240.0, 300.0]);
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.title(5).min, pos2(300.0, 80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_frame_and_title_bar_work_without_a_dock_area() {
|
||||
use chrome::{ChromeStyle, FloatingFrame, TitleBar};
|
||||
let ctx = Context::default();
|
||||
let id = Id::new("task-pane");
|
||||
let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0));
|
||||
let mut time = 0.0;
|
||||
let mut frame = |events: Vec<Event>| {
|
||||
time += 1.0 / 60.0;
|
||||
let input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() };
|
||||
let mut result = None;
|
||||
done(ctx.run_ui(input, |ui| {
|
||||
let style = ChromeStyle::from_ui(ui);
|
||||
let shown = FloatingFrame::new(id, rect).show(ui.ctx(), &style, |ui, inner| {
|
||||
let title = Rect::from_min_size(inner.min, vec2(inner.width(), 22.0));
|
||||
let bar = TitleBar::new(id.with("title"), "Move pane").title("Styles").menu("Pane menu").close("Close pane").show(ui, title, &style);
|
||||
(bar.bar.dragged(), bar.bar.drag_delta(), bar.menu.is_some_and(|r| r.clicked()), bar.close.is_some_and(|r| r.clicked()))
|
||||
});
|
||||
result = Some((shown.inner, shown.resized));
|
||||
}));
|
||||
result.unwrap()
|
||||
};
|
||||
frame(vec![]);
|
||||
frame(vec![]);
|
||||
type Shown = ((bool, Vec2, bool, bool), Option<Rect>);
|
||||
let click = |frame: &mut dyn FnMut(Vec<Event>) -> Shown, at| {
|
||||
frame(vec![Event::PointerMoved(at), button(at, true)]);
|
||||
frame(vec![button(at, false)])
|
||||
};
|
||||
let ((_, _, _, closed), _) = click(&mut frame, pos2(390.0, 111.0));
|
||||
assert!(closed, "× 10 pt in from the right");
|
||||
let ((_, _, menu, _), _) = click(&mut frame, pos2(374.0, 111.0));
|
||||
assert!(menu, "▾ left of ×");
|
||||
|
||||
frame(vec![Event::PointerMoved(pos2(200.0, 110.0)), button(pos2(200.0, 110.0), true)]);
|
||||
frame(vec![Event::PointerMoved(pos2(210.0, 115.0))]);
|
||||
let ((dragged, delta, ..), _) = frame(vec![Event::PointerMoved(pos2(230.0, 125.0))]);
|
||||
assert!(dragged && delta == vec2(20.0, 10.0), "{delta:?}");
|
||||
frame(vec![button(pos2(230.0, 125.0), false)]);
|
||||
|
||||
let edge = pos2(398.0, 200.0);
|
||||
frame(vec![Event::PointerMoved(edge), button(edge, true)]);
|
||||
frame(vec![Event::PointerMoved(edge + vec2(10.0, 0.0))]);
|
||||
let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]);
|
||||
assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() {
|
||||
let layout = Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(200.0),
|
||||
first: Box::new(tabs(&[9], 0)),
|
||||
second: Box::new(Node::Split {
|
||||
axis: SplitAxis::Vertical,
|
||||
size: SplitSize::Ratio(0.5),
|
||||
first: Box::new(tabs(&[1], 0)),
|
||||
second: Box::new(tabs(&[2], 0)),
|
||||
}),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
let mut d = Driver::new(layout, Profile::Frame);
|
||||
d.hide = Hide::Keep;
|
||||
d.content.keep = vec![1, 2];
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others");
|
||||
let divider = d.response(Id::new(AREA).with(("split", &Vec::<bool>::new()))).unwrap().center();
|
||||
let out = d.hold(divider, divider + vec2(0.0, 60.0));
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions);
|
||||
let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
|
||||
assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5)));
|
||||
}
|
||||
@@ -527,7 +527,7 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() {
|
||||
let mut d = Driver::new(layout);
|
||||
let from = d.rect("drag-bar", 6).center();
|
||||
let out = d.drag(from, pos2(700.0, 300.0));
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 200.0]) })), "{:?}", out.actions);
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 191.5]) })), "{:?}", out.actions);
|
||||
assert_eq!(d.layout.floating.len(), 2);
|
||||
assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]);
|
||||
assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Validate deserialized layouts before rendering them. Every transaction checks its complete
|
||||
//! input and commits only a valid result; a rejected move cannot lose the source panel.
|
||||
|
||||
pub mod chrome;
|
||||
mod dock_model;
|
||||
mod model;
|
||||
mod render;
|
||||
@@ -15,7 +16,10 @@ mod workspace;
|
||||
|
||||
pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target};
|
||||
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
|
||||
pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag, is_dragging};
|
||||
pub use surface::{
|
||||
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag,
|
||||
is_dragging,
|
||||
};
|
||||
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
@@ -24,6 +28,8 @@ use surface::{Drag, GroupDrag, GroupGesture};
|
||||
#[cfg(test)]
|
||||
mod dock_tests;
|
||||
#[cfg(test)]
|
||||
mod floating_tests;
|
||||
#[cfg(test)]
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
mod model_tests;
|
||||
|
||||
@@ -169,6 +169,10 @@ pub enum Placement<P> {
|
||||
Group { below: bool },
|
||||
/// A new column before (towards the window edge) or after the anchor's column.
|
||||
Column { after: bool },
|
||||
/// A new frame along one edge of the docked layout, a quarter of its width or height: the
|
||||
/// root is split, or in a layout with docks, the part between them. The anchor names any
|
||||
/// docked panel. `Center` is invalid.
|
||||
Edge(Zone),
|
||||
}
|
||||
|
||||
/// A closed panel's former placement. Applications may retain this beside visibility flags.
|
||||
@@ -572,7 +576,8 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
}
|
||||
|
||||
/// Restore an absent panel at a captured location. Missing anchors are reported without
|
||||
/// changes, allowing the app to choose an explicit default. Unrelated tabs retain selection.
|
||||
/// changes, allowing the app to choose an explicit default; a floating panel whose
|
||||
/// window is gone floats at its last rect instead. Unrelated tabs retain selection.
|
||||
pub fn restore(&mut self, panel: P, location: &Location<P>) -> Result<(), DockError> {
|
||||
self.validate()?;
|
||||
if self.contains(&panel) {
|
||||
@@ -604,10 +609,8 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
if !valid_rect(rect) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
if let Some(anchor) = &location.anchor {
|
||||
if !proposed.floating.iter().any(|g| g.contains(anchor)) {
|
||||
return Err(DockError::MissingTarget);
|
||||
}
|
||||
// A window that closed as a whole reopens at its last rect.
|
||||
if let Some(anchor) = location.anchor.as_ref().filter(|anchor| proposed.floating.iter().any(|g| g.contains(anchor))) {
|
||||
if let Placement::Group { .. } = location.placement {
|
||||
proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?;
|
||||
} else {
|
||||
@@ -970,7 +973,9 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
|
||||
fn insert(&mut self, entry: StackEntry<P>, anchor: &P, placement: Placement<P>) -> Result<(), DockError> {
|
||||
let place = self.place(anchor).ok_or(DockError::MissingTarget)?;
|
||||
if matches!(place, Place::Dock { .. } | Place::Floating { .. }) || matches!(placement, Placement::Group { .. } | Placement::Column { .. }) {
|
||||
if matches!(place, Place::Dock { .. } | Place::Floating { .. })
|
||||
|| matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_))
|
||||
{
|
||||
let group = Group::new(vec![entry.panel], 0);
|
||||
return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement });
|
||||
}
|
||||
|
||||
@@ -470,3 +470,26 @@ mod serde_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_placement_splits_the_part_between_the_docks_or_the_root() {
|
||||
// Dock layout: a floating window lands under the frames, beside the end dock.
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveWindow { panel: 6, to: Target::At { anchor: 1, placement: Placement::Edge(Zone::Bottom) } }).unwrap();
|
||||
let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("{layout:?}") };
|
||||
assert!(matches!(&**second, Node::Dock { .. }));
|
||||
let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(size), second: bottom, .. } = &**first else { panic!("{first:?}") };
|
||||
assert_eq!(*size, 0.75);
|
||||
assert_eq!(**bottom, tabs(&[6, 7, 8], 1));
|
||||
assert!(layout.floating.is_empty());
|
||||
|
||||
// Frame layout: one panel takes the whole left edge.
|
||||
let mut frames = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), tabs(&[2, 3], 0))), floating: Vec::new() };
|
||||
frames.apply(Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }).unwrap();
|
||||
let Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(size), first, .. }) = &frames.root else { panic!() };
|
||||
assert_eq!((*size, &**first), (0.25, &tabs(&[3], 0)));
|
||||
|
||||
let before = frames.clone();
|
||||
assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement));
|
||||
assert_eq!(frames, before);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p
|
||||
|
||||
use super::{GroupKind, Renderer};
|
||||
use crate::docking::{
|
||||
Action, Column, Group, GroupDrag, GroupGesture, Node, Side, Target,
|
||||
Action, Column, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target,
|
||||
targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line},
|
||||
};
|
||||
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
|
||||
@@ -132,7 +132,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
if columns.is_empty() {
|
||||
let (band, line) = column_edge(outer, inward, rect.y_range());
|
||||
let band = band.union(Rect::from_x_y_ranges(
|
||||
outer.min(outer + inward * self.style.edge_band)..=outer.max(outer + inward * self.style.edge_band),
|
||||
outer.min(outer + inward * self.style.new_dock_band)..=outer.max(outer + inward * self.style.new_dock_band),
|
||||
rect.y_range(),
|
||||
));
|
||||
self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
|
||||
@@ -418,16 +418,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
})
|
||||
}
|
||||
|
||||
/// New docks are offered only where the dock profile is in use: in a layout with a dock.
|
||||
fn has_dock(&self) -> bool {
|
||||
self.layout.dock_path(Side::Start).is_some() || self.layout.dock_path(Side::End).is_some()
|
||||
}
|
||||
|
||||
/// Dock targets, with the new-dock bands along the docked area's left and right edges that
|
||||
/// have no dock yet.
|
||||
fn dock_candidates(&self) -> Vec<DockDrop<P>> {
|
||||
let mut candidates = self.dock_targets.clone();
|
||||
if !self.has_dock() || !usable(self.workspace) {
|
||||
if self.style.profile != Profile::Dock || !usable(self.workspace) {
|
||||
return candidates;
|
||||
}
|
||||
let rtl = self.style.right_to_left;
|
||||
@@ -438,8 +433,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
continue;
|
||||
}
|
||||
let (x, inward) = match edge {
|
||||
Edge::Left => (self.workspace.left(), self.style.edge_band),
|
||||
Edge::Right => (self.workspace.right(), -self.style.edge_band),
|
||||
Edge::Left => (self.workspace.left(), self.style.new_dock_band),
|
||||
Edge::Right => (self.workspace.right(), -self.style.new_dock_band),
|
||||
};
|
||||
let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys);
|
||||
let line = vertical_line(x + inward.signum() * 2.0, ys);
|
||||
@@ -496,10 +491,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The move a group gesture makes: the column for a column bar, else the group.
|
||||
/// The move a group gesture makes: the column for a column bar, every group for a window's
|
||||
/// title bar, else the group.
|
||||
pub(super) fn group_action<P: Clone>(drag: &GroupDrag<P>, to: Target<P>) -> Action<P> {
|
||||
match drag.gesture {
|
||||
GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to },
|
||||
GroupGesture::Window | GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to },
|
||||
GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to },
|
||||
GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to },
|
||||
}
|
||||
}
|
||||
@@ -1,176 +1,317 @@
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2};
|
||||
use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2};
|
||||
|
||||
use super::{GroupKind, Renderer, clamp_float};
|
||||
use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize};
|
||||
use super::{GroupKind, Renderer, clamp_float, node_limits};
|
||||
use crate::docking::chrome::{ChromeStyle, FloatingFrame, TitleBar};
|
||||
use crate::docking::{Action, DropProposal, Floating, Group, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target};
|
||||
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
|
||||
|
||||
/// Floating window resize handles: id suffix, hit rect and the edges they move.
|
||||
fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] {
|
||||
[
|
||||
("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)),
|
||||
(
|
||||
"resize-left",
|
||||
Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)),
|
||||
vec2(-1.0, 0.0),
|
||||
),
|
||||
(
|
||||
"resize-right",
|
||||
Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)),
|
||||
vec2(1.0, 0.0),
|
||||
),
|
||||
(
|
||||
"resize-bottom",
|
||||
Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())),
|
||||
vec2(0.0, 1.0),
|
||||
),
|
||||
(
|
||||
"resize-bottom-left",
|
||||
Rect::from_min_max(
|
||||
pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())),
|
||||
pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()),
|
||||
),
|
||||
vec2(-1.0, 1.0),
|
||||
),
|
||||
]
|
||||
pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES;
|
||||
|
||||
/// Height of the bar between two stacked groups of a floating window.
|
||||
const GAP: f32 = 3.0;
|
||||
|
||||
/// A floating stack as drawn, for its band and seam targets.
|
||||
pub(in crate::docking) struct StackHit<P> {
|
||||
pub(super) window: Rect,
|
||||
/// Each group's members, rect and tab strip, top to bottom.
|
||||
pub(super) groups: Vec<(Vec<P>, Rect, Rect)>,
|
||||
/// The upper group's index and the band of each seam.
|
||||
pub(super) seams: Vec<(usize, Rect)>,
|
||||
}
|
||||
|
||||
/// Names of every floating window resize handle, for focus classification.
|
||||
pub(in crate::docking) const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"];
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
pub(in crate::docking) fn floating(&mut self, ctx: &Context) {
|
||||
pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating<P>]) {
|
||||
let screen = ctx.content_rect();
|
||||
if !crate::docking::targets::usable(screen) {
|
||||
return;
|
||||
}
|
||||
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
|
||||
let mut windows: Vec<_> = self.layout.floating.iter().collect();
|
||||
let mut windows: Vec<_> = windows.iter().collect();
|
||||
windows.sort_by_key(|window| {
|
||||
let id = window.panels().next().map(|panel| self.id.with(("floating", panel)));
|
||||
layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX)
|
||||
});
|
||||
#[cfg(feature = "native-windows")]
|
||||
let windows = self.native_windows(ctx, windows);
|
||||
for window in windows {
|
||||
let Some(first) = window.panels().next() else { continue };
|
||||
let [x, y, width, height] = window.rect;
|
||||
let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 });
|
||||
let mut size = vec2(width, height).max(limits.min).min(limits.max);
|
||||
if window.groups.iter().all(|group| group.minimized) {
|
||||
size.y = size.y.min(window.groups.len() as f32 * self.style.tab_height);
|
||||
}
|
||||
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| window.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).fade_in(false).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());
|
||||
// Opaque even when the host's panel colours are transparent.
|
||||
ui.painter().rect_filled(rect, 0.0, self.style.floating_background);
|
||||
self.stacked_groups(ui, &window.groups, rect, GroupKind::Floating { window: rect });
|
||||
ui.painter().rect_stroke(rect, 0.0, self.style.floating_stroke, egui::StrokeKind::Outside);
|
||||
self.resize_handles(ui, window, id, rect, limits, screen);
|
||||
});
|
||||
self.floating_window(ctx, window, screen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchored resize from the side edges, the bottom edge and both bottom corners: the
|
||||
/// opposite edges stay where they are.
|
||||
fn resize_handles(&mut self, ui: &mut Ui, window: &Floating<P>, id: Id, rect: Rect, limits: PanelLimits, screen: Rect) {
|
||||
/// Size limits of a floating window: its title bar, each group's strip and the bodies of
|
||||
/// the groups that are not minimized.
|
||||
pub(super) fn window_limits(&self, window: &Floating<P>) -> PanelLimits {
|
||||
let mut limits = PanelLimits { min: vec2(0.0, self.style.title_height), max: vec2(1_000_000.0, self.style.title_height) };
|
||||
for (index, group) in window.groups.iter().enumerate() {
|
||||
let policy = node_limits(&Node::Tabs { panels: group.panels.clone(), active: 0 }, self.style, &*self.content);
|
||||
let gap = if index > 0 { GAP } else { 0.0 };
|
||||
limits.min.x = limits.min.x.max(policy.min.x);
|
||||
limits.max.x = limits.max.x.min(policy.max.x);
|
||||
let (min, max) = if group.minimized { (self.style.tab_height, self.style.tab_height) } else { (policy.min.y, policy.max.y) };
|
||||
limits.min.y += min + gap;
|
||||
limits.max.y += max + gap;
|
||||
}
|
||||
let min = limits.min.min(vec2(1_000_000.0, 1_000_000.0));
|
||||
PanelLimits { min, max: limits.max.max(min).min(vec2(1_000_000.0, 1_000_000.0)) }
|
||||
}
|
||||
|
||||
fn floating_window(&mut self, ctx: &Context, window: &Floating<P>, screen: Rect) {
|
||||
let Some(first) = window.panels().next() else { return };
|
||||
for (name, handle, direction) in handles(rect) {
|
||||
if !crate::docking::targets::usable(handle) {
|
||||
continue;
|
||||
let [x, y, width, height] = window.rect;
|
||||
let limits = self.window_limits(window);
|
||||
let size = vec2(width, height).max(limits.min).min(limits.max);
|
||||
let rect = Rect::from_min_size(pos2(x, y), size);
|
||||
let id = self.id.with(("floating", first));
|
||||
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
|
||||
ctx.move_to_top(FloatingFrame::layer(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();
|
||||
}
|
||||
let movable = window.panels().all(|panel| self.permissions(panel).movable);
|
||||
let style = ChromeStyle::from_dock(self.style);
|
||||
let resize_label = self.style.resize_window_label.clone();
|
||||
let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show(
|
||||
ctx,
|
||||
&style,
|
||||
|ui, rect| self.window_contents(ui, window, rect, &style),
|
||||
);
|
||||
for handle in &response.handles {
|
||||
self.register_interaction(ctx, handle.id);
|
||||
}
|
||||
let down = ctx.input(|i| i.pointer.primary_down());
|
||||
ctx.data_mut(|data| {
|
||||
let key = self.id.with("pointer-resize");
|
||||
if let Some(started) = response.resize_started {
|
||||
data.insert_temp(key, started);
|
||||
} else if !down && data.get_temp::<Id>(key).is_some_and(|id| response.handles.iter().any(|h| h.id == id)) {
|
||||
data.remove::<Id>(key);
|
||||
}
|
||||
let cursor = if direction.x == 0.0 {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else if direction.y == 0.0 {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
} else if direction.x == direction.y {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
});
|
||||
if let Some(resized) = response.resized {
|
||||
self.output
|
||||
.actions
|
||||
.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] });
|
||||
}
|
||||
}
|
||||
|
||||
/// The title bar, then the stacked groups below it.
|
||||
pub(super) fn window_contents(&mut self, ui: &mut Ui, window: &Floating<P>, rect: Rect, style: &ChromeStyle) {
|
||||
let title = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.title_height).min(rect.bottom())));
|
||||
self.title_bar(ui, window, title, rect, style);
|
||||
let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max);
|
||||
self.floating_stack(ui, &window.groups, body, rect);
|
||||
}
|
||||
|
||||
/// Drag moves the whole window live, double-click minimizes or expands every group, ×
|
||||
/// closes every panel in it.
|
||||
fn title_bar(&mut self, ui: &mut Ui, window: &Floating<P>, title: Rect, rect: Rect, style: &ChromeStyle) {
|
||||
let Some(first) = window.panels().next() else { return };
|
||||
let movable = window.panels().all(|p| self.permissions(p).movable);
|
||||
let closable = window.panels().all(|p| self.permissions(p).closable);
|
||||
let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone());
|
||||
let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable);
|
||||
if closable {
|
||||
bar = bar.close(&close_label);
|
||||
}
|
||||
let response = bar.show(ui, title, style);
|
||||
self.register_interaction(ui.ctx(), response.bar.id);
|
||||
if movable && response.bar.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(title.center());
|
||||
let drag = GroupDrag {
|
||||
panel: first.clone(),
|
||||
original: [rect.left(), rect.top(), rect.width(), rect.height()],
|
||||
gesture: GroupGesture::Window,
|
||||
members: window.panels().cloned().collect(),
|
||||
origin,
|
||||
source: rect,
|
||||
};
|
||||
let response = ui.interact(handle, id.with(name), Sense::drag()).on_hover_cursor(cursor);
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label));
|
||||
let anchor_id = response.id.with("pointer-anchor");
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
if let Some(origin) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(anchor) = AnchoredResize::new(rect, origin, direction)
|
||||
{
|
||||
ui.ctx().data_mut(|data| {
|
||||
if let Some(previous) = data.get_temp::<Id>(self.id.with("pointer-resize")) {
|
||||
data.remove::<AnchoredResize>(previous.with("pointer-anchor"));
|
||||
}
|
||||
data.insert_temp(self.id.with("pointer-resize"), response.id);
|
||||
data.insert_temp(anchor_id, anchor);
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
|
||||
}
|
||||
if movable
|
||||
&& response.bar.dragged()
|
||||
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")))
|
||||
&& drag.gesture == GroupGesture::Window
|
||||
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
|
||||
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
|
||||
{
|
||||
// From the press origin, so fractional pointer steps do not accumulate rounding.
|
||||
let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect());
|
||||
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
|
||||
}
|
||||
if response.bar.double_clicked() {
|
||||
let minimized = !window.groups.iter().all(|g| g.minimized);
|
||||
for group in window.groups.iter().filter(|g| g.minimized != minimized) {
|
||||
if let Some(panel) = group.active_panel() {
|
||||
self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized });
|
||||
}
|
||||
}
|
||||
let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused);
|
||||
let mut delta = 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
let pointer_resize = response.dragged() || response.drag_stopped();
|
||||
let limits = PanelLimits { min: limits.min.max(vec2(40.0, self.style.tab_height + 20.0)).min(limits.max), ..limits };
|
||||
let next = if pointer_resize {
|
||||
ui.ctx()
|
||||
.data(|data| data.get_temp::<AnchoredResize>(anchor_id))
|
||||
.and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, screen, limits)))
|
||||
} else if delta != Vec2::ZERO {
|
||||
AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, screen, limits))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let permitted = response.enabled() && window.panels().all(|panel| self.permissions(panel).movable);
|
||||
if permitted
|
||||
&& !cancelled
|
||||
&& let Some(resized) = next.filter(|next| *next != rect)
|
||||
{
|
||||
self.output
|
||||
.actions
|
||||
.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] });
|
||||
}
|
||||
if response.drag_stopped() {
|
||||
response.surrender_focus();
|
||||
}
|
||||
if cancelled || !permitted || !ui.input(|input| input.pointer.primary_down()) {
|
||||
ui.ctx().data_mut(|data| {
|
||||
data.remove::<AnchoredResize>(anchor_id);
|
||||
if data.get_temp::<Id>(self.id.with("pointer-resize")) == Some(response.id) {
|
||||
data.remove::<Id>(self.id.with("pointer-resize"));
|
||||
}
|
||||
});
|
||||
}
|
||||
if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) {
|
||||
ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, self.style.accent));
|
||||
}
|
||||
if let Some(close) = response.close {
|
||||
self.register_interaction(ui.ctx(), close.id);
|
||||
if close.clicked() {
|
||||
self.output.actions.push(Action::CloseWindow { panel: first.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups top to bottom with a bar between them. Minimized groups take their strip; the
|
||||
/// others share the rest by their preferred heights, equally when none is set.
|
||||
pub(super) fn floating_stack(&mut self, ui: &mut Ui, groups: &[Group<P>], body: Rect, window: Rect) {
|
||||
let strip = self.style.tab_height;
|
||||
let open = groups.iter().filter(|g| !g.minimized).count();
|
||||
let fixed = groups.len().saturating_sub(open) as f32 * strip + groups.len().saturating_sub(1) as f32 * GAP;
|
||||
let available = (body.height() - fixed).max(0.0);
|
||||
let equal = if open > 0 { available / open as f32 } else { 0.0 };
|
||||
let wanted = |g: &Group<P>| g.height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or(equal).max(1.0);
|
||||
let total: f32 = groups.iter().filter(|g| !g.minimized).map(wanted).sum();
|
||||
let scale = if total > 0.0 { available / total } else { 0.0 };
|
||||
let mut hit = StackHit { window, groups: Vec::new(), seams: Vec::new() };
|
||||
let mut top = body.top();
|
||||
let mut rects = Vec::new();
|
||||
for (index, group) in groups.iter().enumerate() {
|
||||
if index > 0 {
|
||||
let gap = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + GAP).min(body.bottom())));
|
||||
ui.painter().rect_filled(gap, 0.0, self.style.tab_background);
|
||||
hit.seams.push((index - 1, Rect::from_center_size(gap.center(), vec2(gap.width(), self.style.seam_band))));
|
||||
top = gap.bottom();
|
||||
}
|
||||
let height = if group.minimized { strip } else { wanted(group) * scale };
|
||||
let rect = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + height).min(body.bottom())));
|
||||
self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Floating { window });
|
||||
let strip_rect = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + strip).min(rect.bottom())));
|
||||
hit.groups.push((group.panels.clone(), rect, strip_rect));
|
||||
rects.push(rect);
|
||||
top = rect.bottom();
|
||||
}
|
||||
// Dividers between two open groups resize both; registered after the strips, so their
|
||||
// hit region wins at the shared edge.
|
||||
for (index, pair) in groups.windows(2).enumerate() {
|
||||
let ([upper, lower], Some(a), Some(b)) = (pair, rects.get(index), rects.get(index + 1)) else { continue };
|
||||
if upper.minimized || lower.minimized || scale <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (Some(upper_panel), Some(lower_panel)) = (upper.active_panel(), lower.active_panel()) else { continue };
|
||||
let divider = Rect::from_min_max(pos2(body.left(), a.bottom()), pos2(body.right(), b.top()));
|
||||
let least = strip + self.style.min_pane;
|
||||
let pair_size = a.height() + b.height();
|
||||
let (lo, hi) = if pair_size >= 2.0 * least { (least, pair_size - least) } else { (a.height(), a.height()) };
|
||||
let response = ResizeHandle::new(
|
||||
self.id.with(("floating-divider", upper.panels.first())),
|
||||
SplitAxis::Vertical,
|
||||
divider,
|
||||
&self.style.resize_label,
|
||||
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
|
||||
)
|
||||
.value(a.height(), lo..=hi)
|
||||
.show(ui);
|
||||
self.register_interaction(ui.ctx(), response.response.id);
|
||||
if response.delta != 0.0 {
|
||||
let next = (a.height() + response.delta).clamp(lo, hi);
|
||||
self.output.actions.push(Action::ResizeGroup { panel: upper_panel.clone(), height: Some(next / scale) });
|
||||
self.output.actions.push(Action::ResizeGroup { panel: lower_panel.clone(), height: Some((pair_size - next) / scale) });
|
||||
}
|
||||
}
|
||||
self.frame.stacks.push(hit);
|
||||
}
|
||||
|
||||
/// Targets the group targets do not cover: the bands and seams of a floating stack (P3,
|
||||
/// P4), then the layout's outer edge (P8). None leaves the pointer to the group targets.
|
||||
/// `skip_own` looks through the window holding `members` (a window moving live).
|
||||
pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option<DropProposal<P>> {
|
||||
let own = |panels: &[P]| panels.iter().any(|p| members.contains(p));
|
||||
let window = self.frame.stacks.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && w.groups.iter().any(|(g, ..)| own(g))));
|
||||
if let Some(window) = window {
|
||||
let in_group = window.groups.iter().any(|(_, rect, _)| rect.contains(pointer)) || window.seams.iter().any(|(_, b)| b.contains(pointer));
|
||||
if !in_group {
|
||||
// The title bar occludes what lies behind the window.
|
||||
let anchor = members.first()?.clone();
|
||||
return Some(DropProposal {
|
||||
anchor,
|
||||
placement: Placement::Tab { before: None },
|
||||
preview: Rect::NOTHING,
|
||||
insertion: None,
|
||||
noop: true,
|
||||
});
|
||||
}
|
||||
return self.stack_proposal(window, members, pointer);
|
||||
}
|
||||
let (anchor, zone, preview) = self.edge_proposal(members, pointer)?;
|
||||
Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false })
|
||||
}
|
||||
|
||||
fn stack_proposal(&self, hit: &StackHit<P>, members: &[P], pointer: Pos2) -> Option<DropProposal<P>> {
|
||||
let line = |y: f32| Rect::from_min_max(pos2(hit.window.left(), y - 1.0), pos2(hit.window.right(), y + 1.0));
|
||||
let same = |panels: &[P]| panels == members;
|
||||
// An anchor in the group that is not itself moving.
|
||||
let anchor = |panels: &[P]| panels.iter().find(|p| !members.contains(p)).cloned();
|
||||
for (upper, band) in &hit.seams {
|
||||
if !band.contains(pointer) {
|
||||
continue;
|
||||
}
|
||||
let (Some((a, rect, _)), Some((b, ..))) = (hit.groups.get(*upper), hit.groups.get(upper + 1)) else { return None };
|
||||
let line = line(rect.bottom() + GAP * 0.5);
|
||||
if same(a) || same(b) {
|
||||
let anchor = members.first()?.clone();
|
||||
return Some(DropProposal {
|
||||
anchor,
|
||||
placement: Placement::Group { below: true },
|
||||
preview: Rect::NOTHING,
|
||||
insertion: None,
|
||||
noop: true,
|
||||
});
|
||||
}
|
||||
let (anchor, below) = match anchor(a) {
|
||||
Some(panel) => (panel, true),
|
||||
None => (anchor(b)?, false),
|
||||
};
|
||||
return Some(DropProposal { anchor, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion: Some(line), noop: false });
|
||||
}
|
||||
let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?;
|
||||
if strip.contains(pointer) || same(panels) {
|
||||
return None;
|
||||
}
|
||||
let band = rect.height() * 0.25;
|
||||
let below = if pointer.y < rect.top() + band {
|
||||
false
|
||||
} else if pointer.y > rect.bottom() - band {
|
||||
true
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let insertion = Some(line(if below { rect.bottom() } else { rect.top() }));
|
||||
Some(DropProposal { anchor: anchor(panels)?, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion, noop: false })
|
||||
}
|
||||
|
||||
/// What a group drag carries: the whole window rect for the only group of a floating
|
||||
/// window, so floating it again keeps the window's size; else the group's rect.
|
||||
pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect {
|
||||
match owner.kind {
|
||||
GroupKind::Floating { window }
|
||||
if owner.panels.first().is_some_and(|p| self.layout.floating.iter().any(|w| w.groups.len() == 1 && w.contains(p))) =>
|
||||
{
|
||||
window
|
||||
}
|
||||
_ => owner.rect,
|
||||
}
|
||||
}
|
||||
|
||||
/// Release of a title bar drag. The window already sits where it was dragged; a dock or
|
||||
/// group target docks or merges every group in it, else hosts get the release in
|
||||
/// `group_drop`.
|
||||
pub(super) fn drop_window(&mut self, ctx: &Context, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
|
||||
if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) {
|
||||
self.output.actions.extend(action);
|
||||
return;
|
||||
}
|
||||
let proposal = if float_only { None } else { self.group_proposal(drag, pointer) };
|
||||
match proposal.filter(|p| !p.noop) {
|
||||
Some(proposal) => {
|
||||
let to = Target::At { anchor: proposal.anchor, placement: proposal.placement };
|
||||
self.output.actions.push(super::dock::group_action(drag, to));
|
||||
}
|
||||
None => self.output.group_drop = Some((drag.panel.clone(), pointer)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Frame profile: Maximize Frame, the outer edge band, hidden panels and the frame menu items.
|
||||
|
||||
use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Pos2, Rect, pos2, vec2};
|
||||
|
||||
use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup};
|
||||
use crate::docking::{
|
||||
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Permissions, Profile, Zone,
|
||||
dock_model::{Place, node_at},
|
||||
};
|
||||
|
||||
/// What the renderer draws when it is not the layout itself, and the targets that depend on it.
|
||||
pub(in crate::docking) struct FrameState<P> {
|
||||
/// View path to layout path of each split, when the view is not the layout.
|
||||
paths: Option<HashMap<Vec<bool>, Vec<bool>>>,
|
||||
/// The view node whose rect is the outer edge area; none while a frame is maximized.
|
||||
edge_path: Option<Vec<bool>>,
|
||||
pub(super) edge: Option<Rect>,
|
||||
pub(super) maximized: Option<P>,
|
||||
/// Floating stacks as drawn, back to front.
|
||||
pub(super) stacks: Vec<StackHit<P>>,
|
||||
}
|
||||
|
||||
/// The layout as shown: a maximized frame alone, and hidden panels left out.
|
||||
pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
|
||||
layout: &'a Layout<P>,
|
||||
maximized: Option<&P>,
|
||||
hide: HidePanels<'_, P>,
|
||||
content: &dyn DockContent<P>,
|
||||
) -> (Cow<'a, Layout<P>>, FrameState<P>) {
|
||||
let protected = |p: &P| content.permissions(p) == Permissions::PROTECTED;
|
||||
let hidden = |p: &P| match hide {
|
||||
HidePanels::None => false,
|
||||
HidePanels::All => !protected(p),
|
||||
HidePanels::Except(keep) => !protected(p) && !keep(p),
|
||||
};
|
||||
// Only a docked panel maximizes its frame.
|
||||
let maximized = maximized.and_then(|panel| match layout.place(panel)? {
|
||||
Place::Node(path) => Some((panel.clone(), path.clone(), layout.root.as_ref().and_then(|root| node_at(root, &path))?.clone())),
|
||||
place @ Place::Dock { .. } => {
|
||||
let group = layout.group(&place)?;
|
||||
Some((panel.clone(), path_of(&place), Node::Tabs { panels: group.panels.clone(), active: group.active }))
|
||||
}
|
||||
Place::Floating { .. } => None,
|
||||
});
|
||||
let mut state = FrameState { paths: None, edge_path: None, edge: None, maximized: None, stacks: Vec::new() };
|
||||
if maximized.is_none() && matches!(hide, HidePanels::None) {
|
||||
state.edge_path = Some(layout.edge_path());
|
||||
return (Cow::Borrowed(layout), state);
|
||||
}
|
||||
let mut paths = HashMap::new();
|
||||
let view = match maximized {
|
||||
Some((panel, path, node)) => {
|
||||
state.maximized = Some(panel);
|
||||
Layout { root: prune(&node, path, Vec::new(), &hidden, &mut paths), floating: Vec::new() }
|
||||
}
|
||||
None => {
|
||||
let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths));
|
||||
let floating = layout
|
||||
.floating
|
||||
.iter()
|
||||
.filter_map(|window| {
|
||||
let groups: Vec<_> = window.groups.iter().filter_map(|g| prune_group(g, &hidden)).collect();
|
||||
(!groups.is_empty()).then(|| Floating { groups, rect: window.rect, native: window.native })
|
||||
})
|
||||
.collect();
|
||||
Layout { root, floating }
|
||||
}
|
||||
};
|
||||
if state.maximized.is_none() {
|
||||
state.edge_path = Some(view.edge_path());
|
||||
}
|
||||
state.paths = Some(paths);
|
||||
(Cow::Owned(view), state)
|
||||
}
|
||||
|
||||
fn path_of(place: &Place) -> Vec<bool> {
|
||||
match place {
|
||||
Place::Node(path) | Place::Dock { path, .. } => path.clone(),
|
||||
Place::Floating { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible<P>(node: &Node<P>, hidden: &dyn Fn(&P) -> bool) -> bool {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => visible(first, hidden) || visible(second, hidden),
|
||||
Node::Tabs { panels, .. } => panels.iter().any(|p| !hidden(p)),
|
||||
Node::Stack { entries } => entries.iter().any(|e| !hidden(&e.panel)),
|
||||
Node::Dock { columns, .. } => columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).any(|p| !hidden(p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `node` without hidden panels; a split with one visible side becomes that side. Records the
|
||||
/// layout path of every split kept.
|
||||
fn prune<P: Clone + PartialEq>(
|
||||
node: &Node<P>,
|
||||
path: Vec<bool>,
|
||||
at: Vec<bool>,
|
||||
hidden: &dyn Fn(&P) -> bool,
|
||||
paths: &mut HashMap<Vec<bool>, Vec<bool>>,
|
||||
) -> Option<Node<P>> {
|
||||
let child = |path: &[bool], side: bool| [path, &[side]].concat();
|
||||
match node {
|
||||
Node::Split { axis, size, first, second } => match (visible(first, hidden), visible(second, hidden)) {
|
||||
(true, true) => {
|
||||
let first = prune(first, child(&path, false), child(&at, false), hidden, paths)?;
|
||||
let second = prune(second, child(&path, true), child(&at, true), hidden, paths)?;
|
||||
paths.insert(at, path);
|
||||
Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) })
|
||||
}
|
||||
(true, false) => prune(first, child(&path, false), at, hidden, paths),
|
||||
(false, true) => prune(second, child(&path, true), at, hidden, paths),
|
||||
(false, false) => None,
|
||||
},
|
||||
Node::Tabs { panels, active } => {
|
||||
let group = prune_group(&Group::new(panels.clone(), *active), hidden)?;
|
||||
Some(Node::Tabs { panels: group.panels, active: group.active })
|
||||
}
|
||||
Node::Stack { entries } => {
|
||||
let entries: Vec<_> = entries.iter().filter(|e| !hidden(&e.panel)).cloned().collect();
|
||||
(!entries.is_empty()).then_some(Node::Stack { entries })
|
||||
}
|
||||
Node::Dock { side, columns } => {
|
||||
let columns: Vec<_> = columns
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let groups: Vec<_> = c.groups.iter().filter_map(|g| prune_group(g, hidden)).collect();
|
||||
(!groups.is_empty()).then(|| Column { groups, ..c.clone() })
|
||||
})
|
||||
.collect();
|
||||
(!columns.is_empty()).then_some(Node::Dock { side: *side, columns })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group without hidden panels; the selected panel stays selected when it is shown.
|
||||
fn prune_group<P: Clone + PartialEq>(group: &Group<P>, hidden: &dyn Fn(&P) -> bool) -> Option<Group<P>> {
|
||||
let selected = group.active_panel();
|
||||
let panels: Vec<P> = group.panels.iter().filter(|p| !hidden(p)).cloned().collect();
|
||||
let active = selected.and_then(|s| panels.iter().position(|p| p == s)).unwrap_or(0);
|
||||
(!panels.is_empty()).then(|| Group { panels, active, ..group.clone() })
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) {
|
||||
if self.frame.edge_path.as_deref() == Some(path) {
|
||||
self.frame.edge = Some(rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout path of a split drawn at view path `path`.
|
||||
pub(super) fn original_path(&self, path: &[bool]) -> Vec<bool> {
|
||||
self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec())
|
||||
}
|
||||
|
||||
/// The outer edge band under the pointer (P8): top and bottom in both profiles, left and
|
||||
/// right in the frame profile. Returns a docked anchor that is not moving, the zone and
|
||||
/// the preview of the new frame.
|
||||
pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> {
|
||||
let area = self.frame.edge.filter(|area| area.contains(pointer))?;
|
||||
let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0);
|
||||
let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)];
|
||||
if self.style.profile == Profile::Frame {
|
||||
edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]);
|
||||
}
|
||||
let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
if distance > band {
|
||||
return None;
|
||||
}
|
||||
let anchor = self
|
||||
.layout
|
||||
.panels()
|
||||
.into_iter()
|
||||
.find(|p| !members.contains(p) && !matches!(self.layout.place(p), Some(Place::Floating { .. }) | None))?
|
||||
.clone();
|
||||
let size = area.size() * 0.25;
|
||||
let preview = match zone {
|
||||
Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())),
|
||||
Zone::Right => Rect::from_min_max(pos2(area.right() - size.x, area.top()), area.max),
|
||||
Zone::Top => Rect::from_min_size(area.min, vec2(area.width(), size.y)),
|
||||
Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(area.left(), area.bottom() - size.y), area.max),
|
||||
};
|
||||
Some((anchor, zone, preview))
|
||||
}
|
||||
|
||||
pub(super) fn float_label(&self) -> String {
|
||||
if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() }
|
||||
}
|
||||
|
||||
pub(super) fn float_group_label(&self) -> String {
|
||||
if self.style.profile == Profile::Frame { self.style.undock_group_label.clone() } else { self.style.float_group_label.clone() }
|
||||
}
|
||||
|
||||
pub(super) fn close_group_label(&self) -> String {
|
||||
if self.style.profile == Profile::Frame { self.style.close_panel_group_label.clone() } else { self.style.close_group_label.clone() }
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9).
|
||||
pub(super) fn latch_undock(&self, ctx: &Context, kind: Option<GroupKind>) {
|
||||
let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd);
|
||||
if self.style.profile == Profile::Frame && kind == Some(GroupKind::Frame) && command {
|
||||
ctx.data_mut(|d| {
|
||||
if let Some(drag) = d.get_temp::<crate::docking::Drag<P>>(self.id.with("drag")) {
|
||||
d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame items of the menu tail: Close Other Panels in Group and Maximize Frame (frame
|
||||
/// profile), and Move Panel to New Window where native windows are available.
|
||||
pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
|
||||
if self.style.profile == Profile::Frame {
|
||||
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
|
||||
if owner.panels.len() > 1 && others && self.menu_action(ui, &self.style.close_others_label.clone()) {
|
||||
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
|
||||
}
|
||||
if owner.kind == GroupKind::Frame {
|
||||
let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m));
|
||||
let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() };
|
||||
if self.menu_action(ui, &label) {
|
||||
let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() };
|
||||
self.output.events.push(DockEvent::ToggleMaximize { panel });
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "native-windows")]
|
||||
self.new_window_item(ui, owner, panel);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash};
|
||||
use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
|
||||
|
||||
use super::{Renderer, clamp_float, stripless};
|
||||
use crate::docking::{Action, DropTarget, Group, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING};
|
||||
use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING};
|
||||
use crate::tabs;
|
||||
|
||||
/// Where a group is drawn. It decides the drop targets the group offers and its gestures.
|
||||
@@ -26,28 +26,10 @@ pub(super) struct TabGroup<'a, P> {
|
||||
pub(super) rect: Rect,
|
||||
}
|
||||
|
||||
/// Width of a floating window's move grip at the left of each strip.
|
||||
const GRIP_WIDTH: f32 = 12.0;
|
||||
/// The top band of a strip that drags the whole group.
|
||||
const BORDER_BAND: f32 = 4.0;
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
/// Groups stacked top to bottom. Minimized groups take their strip; the others share the
|
||||
/// rest equally.
|
||||
pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[Group<P>], rect: Rect, kind: GroupKind) {
|
||||
let strip = self.style.tab_height;
|
||||
let open = groups.iter().filter(|g| !g.minimized).count();
|
||||
let fixed = groups.len().saturating_sub(open) as f32 * strip;
|
||||
let share = if open > 0 { (rect.height() - fixed).max(0.0) / open as f32 } else { 0.0 };
|
||||
let mut top = rect.top();
|
||||
for group in groups {
|
||||
let bottom = (top + if group.minimized { strip } else { share }).min(rect.bottom());
|
||||
let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), bottom));
|
||||
self.group(ui, &group.panels, group.active, group.minimized, group_rect, kind);
|
||||
top = bottom;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) {
|
||||
let Some(selected) = panels.get(active) else { return };
|
||||
let policy = self.permissions(selected);
|
||||
@@ -65,13 +47,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
return;
|
||||
}
|
||||
let movable = panels.iter().all(|p| self.permissions(p).movable);
|
||||
let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
|
||||
let 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 let GroupKind::Floating { window } = kind {
|
||||
let grip = Rect::from_min_max(strip.min, pos2((strip.left() + GRIP_WIDTH).min(strip.right()), strip.bottom()));
|
||||
self.window_grip(ui, panels, grip, window, movable);
|
||||
strip.min.x = grip.right();
|
||||
}
|
||||
let floating = matches!(kind, GroupKind::Floating { .. });
|
||||
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 };
|
||||
@@ -149,38 +126,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// The floating window's own move: the window follows the pointer, every group included.
|
||||
fn window_grip(&mut self, ui: &mut Ui, panels: &[P], grip: Rect, window: Rect, movable: bool) {
|
||||
let Some(first) = panels.first() else { return };
|
||||
let response = ui.interact(grip, self.id.with(("move-group", Some(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 movable && response.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
|
||||
let drag = GroupDrag {
|
||||
panel: first.clone(),
|
||||
original: [window.left(), window.top(), window.width(), window.height()],
|
||||
gesture: GroupGesture::Window,
|
||||
members: panels.to_vec(),
|
||||
origin,
|
||||
source: window,
|
||||
};
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
|
||||
}
|
||||
if movable
|
||||
&& response.dragged()
|
||||
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")))
|
||||
&& drag.gesture == GroupGesture::Window
|
||||
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
|
||||
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
|
||||
{
|
||||
// From the press origin, so fractional pointer steps do not accumulate rounding.
|
||||
let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect());
|
||||
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);
|
||||
}
|
||||
|
||||
/// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band
|
||||
/// (`drag-border`: drag only). Both move the whole group.
|
||||
fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) {
|
||||
@@ -194,7 +139,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label));
|
||||
if movable && response.drag_started() {
|
||||
self.start_group_drag(ui, owner.panels, owner.rect);
|
||||
self.start_group_drag(ui, owner.panels, self.drag_source(owner));
|
||||
}
|
||||
if !bar {
|
||||
return;
|
||||
@@ -242,7 +187,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
let closable = all(&|p| p.closable);
|
||||
let movable = all(&|p| p.movable);
|
||||
let floatable = movable && all(&|p| p.floatable);
|
||||
if closable && self.menu_action(ui, &self.style.close_group_label.clone()) {
|
||||
if closable && self.menu_action(ui, &self.close_group_label()) {
|
||||
self.output.actions.push(Action::CloseGroup { panel: panel.clone() });
|
||||
}
|
||||
if owner.kind != GroupKind::Frame {
|
||||
@@ -254,7 +199,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) {
|
||||
self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true });
|
||||
}
|
||||
if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) {
|
||||
if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) {
|
||||
let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
|
||||
self.output.actions.push(Action::MoveGroup {
|
||||
panel: panel.clone(),
|
||||
@@ -280,6 +225,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
});
|
||||
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical));
|
||||
}
|
||||
self.frame_items(ui, owner, panel);
|
||||
}
|
||||
|
||||
/// A stock menu button with shaped text and its logical accessibility label; true when
|
||||
@@ -356,15 +302,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
{
|
||||
self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized });
|
||||
}
|
||||
if policy.movable && response.drag_started() {
|
||||
begin_drag(ui.ctx(), self.id, panel.clone(), group);
|
||||
if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) {
|
||||
self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind));
|
||||
}
|
||||
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 logical = self.float_label();
|
||||
let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text);
|
||||
let response = ui.button(text);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical));
|
||||
if response.clicked() {
|
||||
let size = self.float_size(panel, group.size());
|
||||
let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect());
|
||||
|
||||
@@ -8,6 +8,11 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node
|
||||
|
||||
mod dock;
|
||||
mod floating;
|
||||
mod frame;
|
||||
#[cfg(feature = "native-windows")]
|
||||
mod native;
|
||||
|
||||
pub(in crate::docking) use frame::{FrameState, view};
|
||||
|
||||
pub(in crate::docking) use group::GroupKind;
|
||||
|
||||
@@ -29,6 +34,7 @@ pub(super) struct Renderer<'a, P> {
|
||||
pub(super) dock_targets: Vec<crate::docking::targets::DockDrop<P>>,
|
||||
pub(super) owned_interactions: Vec<Id>,
|
||||
pub(super) output: DockOutput<P>,
|
||||
pub(super) frame: FrameState<P>,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
@@ -65,6 +71,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
if !crate::docking::targets::usable(rect) {
|
||||
return;
|
||||
}
|
||||
self.note_edge(path, rect);
|
||||
match node {
|
||||
Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {}
|
||||
Node::Split { axis, size, first, second } => {
|
||||
@@ -93,7 +100,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
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 });
|
||||
self.output.actions.push(Action::ResizeSplit { path: self.original_path(path), size });
|
||||
}
|
||||
}
|
||||
Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame),
|
||||
@@ -263,12 +270,7 @@ pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, cont
|
||||
}
|
||||
|
||||
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)
|
||||
crate::docking::chrome::clamp(rect, screen)
|
||||
}
|
||||
|
||||
pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Floating windows in their own OS windows (feature `native-windows`). A window with a native
|
||||
//! rect is drawn with `show_viewport_immediate` where the backend has viewports; where egui
|
||||
//! embeds viewports (wasm, tests) it stays in-window at its in-window rect. Tabs and groups do
|
||||
//! not drag between OS windows.
|
||||
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Pos2, Rect, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, vec2};
|
||||
|
||||
use super::{Renderer, clamp_float, group::TabGroup};
|
||||
use crate::docking::{Action, Drag, Floating, GroupDrag};
|
||||
|
||||
/// Smallest and largest native window, and the farthest it may sit from the desktop origin.
|
||||
const MIN_SIZE: [f32; 2] = [160.0, 100.0];
|
||||
const MAX_SIZE: f32 = 16_384.0;
|
||||
const MAX_POSITION: f32 = 100_000.0;
|
||||
|
||||
/// A rect safe to hand to the windowing system: finite, its size and position bounded.
|
||||
/// Hostile workspace files and bad monitor reports included.
|
||||
pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] {
|
||||
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
|
||||
let [x, y, w, h] = rect;
|
||||
[
|
||||
finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
|
||||
finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
|
||||
finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE),
|
||||
finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE),
|
||||
]
|
||||
}
|
||||
|
||||
/// The desktop rect a native viewport reports: outer position, inner size. None when the
|
||||
/// backend reports nothing usable.
|
||||
fn placed(outer: Option<Rect>, inner: Option<Rect>) -> Option<[f32; 4]> {
|
||||
let (outer, inner) = (outer?, inner?);
|
||||
(outer.min.is_finite() && inner.size().is_finite()).then(|| sanitized([outer.min.x, outer.min.y, inner.width(), inner.height()]))
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
/// Show the native windows and return the windows to draw in-window.
|
||||
pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating<P>>) -> Vec<&'w Floating<P>> {
|
||||
let native = !ctx.embed_viewports();
|
||||
let mut shown = Vec::new();
|
||||
let mut in_window = Vec::new();
|
||||
for window in windows {
|
||||
match window.native {
|
||||
Some(rect) if native && window.panels().all(|p| self.content.allow_native(p)) => {
|
||||
if let Some(id) = self.native_window(ctx, window, rect) {
|
||||
shown.push(id);
|
||||
}
|
||||
}
|
||||
_ => in_window.push(window),
|
||||
}
|
||||
}
|
||||
// Forget where windows that are no longer native were told to open.
|
||||
let key = self.id.with("native-shown");
|
||||
let previous = ctx.data_mut(|d| d.remove_temp::<Vec<egui::Id>>(key)).unwrap_or_default();
|
||||
ctx.data_mut(|d| {
|
||||
for id in previous.iter().filter(|id| !shown.contains(id)) {
|
||||
d.remove::<[f32; 4]>(*id);
|
||||
}
|
||||
d.insert_temp(key, shown);
|
||||
});
|
||||
in_window
|
||||
}
|
||||
|
||||
/// One floating window in its own viewport. Returns the key of its spawn rect.
|
||||
fn native_window(&mut self, ctx: &Context, window: &Floating<P>, rect: [f32; 4]) -> Option<egui::Id> {
|
||||
let first = window.panels().next()?.clone();
|
||||
let viewport = ViewportId::from_hash_of((self.id, "native", &first));
|
||||
// The window opens at its saved rect; after that the window system owns its position
|
||||
// and size, which are read back below.
|
||||
let spawn_key = self.id.with(("native-spawn", &first));
|
||||
let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_key, || sanitized(rect)));
|
||||
let title = window.groups.first().and_then(|g| g.active_panel()).map(|p| self.label_of(p)).unwrap_or_default();
|
||||
let builder = ViewportBuilder::default()
|
||||
.with_title(title)
|
||||
.with_position([spawn[0], spawn[1]])
|
||||
.with_inner_size([spawn[2], spawn[3]])
|
||||
.with_min_inner_size(MIN_SIZE);
|
||||
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
|
||||
ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus);
|
||||
self.output.focused = self.activation.clone();
|
||||
}
|
||||
let (targets, stacks) = (self.targets.len(), self.frame.stacks.len());
|
||||
let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| {
|
||||
let rect = ui.max_rect();
|
||||
ui.painter().rect_filled(rect, 0.0, self.style.floating_background);
|
||||
self.floating_stack(ui, &window.groups, rect, rect);
|
||||
// An embedded fallback reads the main window's input: its close request and place
|
||||
// are not this window's.
|
||||
if matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) {
|
||||
ui.input(|i| (i.viewport().close_requested(), placed(i.viewport().outer_rect, i.viewport().inner_rect)))
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
});
|
||||
// Targets in another OS window are in its coordinates, and no drag crosses windows.
|
||||
self.targets.truncate(targets);
|
||||
self.frame.stacks.truncate(stacks);
|
||||
ctx.data_mut(|d| {
|
||||
if d.get_temp::<Drag<P>>(self.id.with("drag")).is_some_and(|drag| window.contains(&drag.panel)) {
|
||||
d.remove::<Drag<P>>(self.id.with("drag"));
|
||||
}
|
||||
if d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")).is_some_and(|drag| window.contains(&drag.panel)) {
|
||||
d.remove::<GroupDrag<P>>(self.id.with("group-drag"));
|
||||
}
|
||||
});
|
||||
if closed {
|
||||
ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_key));
|
||||
self.output.actions.push(Action::SetNative { panel: first, rect: None });
|
||||
return None;
|
||||
}
|
||||
if let Some(placed) = placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) {
|
||||
self.output.actions.push(Action::SetNative { panel: first, rect: Some(placed) });
|
||||
}
|
||||
Some(spawn_key)
|
||||
}
|
||||
|
||||
/// Move Panel to New Window: a floating panel's window, or the panel floated on its own.
|
||||
pub(super) fn new_window_item(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if ctx.embed_viewports() {
|
||||
return;
|
||||
}
|
||||
let window = self.layout.floating.iter().find(|w| w.contains(panel));
|
||||
if window.is_some_and(|w| w.native.is_some()) {
|
||||
return;
|
||||
}
|
||||
let movers: Vec<P> = window.map_or_else(|| vec![panel.clone()], |w| w.panels().cloned().collect());
|
||||
let allowed = |p: &P| self.content.allow_native(p) && self.permissions(p).floatable && self.permissions(p).movable;
|
||||
if !movers.iter().all(allowed) {
|
||||
return;
|
||||
}
|
||||
let in_window = match window {
|
||||
Some(window) => window.rect,
|
||||
None => {
|
||||
let size = self.float_size(panel, owner.rect.size());
|
||||
let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ctx.content_rect());
|
||||
[rect.left(), rect.top(), rect.width(), rect.height()]
|
||||
}
|
||||
};
|
||||
if !self.menu_action(ui, &self.style.new_window_label.clone()) {
|
||||
return;
|
||||
}
|
||||
// Over the panel's place in the app window: its content's position on the desktop.
|
||||
let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO);
|
||||
let [x, y, w, h] = in_window;
|
||||
if window.is_none() {
|
||||
self.output.actions.push(Action::Float { panel: panel.clone(), rect: in_window });
|
||||
}
|
||||
let native = sanitized([origin.x + x, origin.y + y, w, h]);
|
||||
self.output.actions.push(Action::SetNative { panel: panel.clone(), rect: Some(native) });
|
||||
}
|
||||
}
|
||||
@@ -58,22 +58,31 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return };
|
||||
match drag.gesture {
|
||||
GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)),
|
||||
GroupGesture::Window => self.drop_window(ctx, &drag, pointer, float_only),
|
||||
GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only),
|
||||
}
|
||||
} else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) {
|
||||
self.output.group_drag = Some(drag.panel.clone());
|
||||
if drag.gesture != GroupGesture::Window {
|
||||
self.paint_group_drag(ctx, &drag, pointer, float_only);
|
||||
}
|
||||
self.paint_group_drag(ctx, &drag, pointer, float_only);
|
||||
}
|
||||
}
|
||||
|
||||
/// The valid target under the pointer for a whole group. A target holding the group
|
||||
/// itself is a no-op proposal; a target the model would reject is no target.
|
||||
fn group_proposal(&self, drag: &GroupDrag<P>, pointer: Pos2) -> Option<DropProposal<P>> {
|
||||
let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?;
|
||||
if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) {
|
||||
/// itself is a no-op proposal; a target the model would reject is no target. A window
|
||||
/// moving live looks through itself and does not split frames or join a body's centre.
|
||||
pub(super) fn group_proposal(&self, drag: &GroupDrag<P>, pointer: Pos2) -> Option<DropProposal<P>> {
|
||||
let window = drag.gesture == GroupGesture::Window;
|
||||
let valid = |proposal: DropProposal<P>| {
|
||||
let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() };
|
||||
(proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal)
|
||||
};
|
||||
if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) {
|
||||
return valid(proposal);
|
||||
}
|
||||
let own = |t: &crate::docking::DropTarget<P>| t.tabs.iter().any(|(p, _)| drag.members.contains(p));
|
||||
let target =
|
||||
self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?;
|
||||
if own(target) {
|
||||
return Some(DropProposal {
|
||||
anchor: drag.panel.clone(),
|
||||
placement: Placement::Tab { before: None },
|
||||
@@ -83,8 +92,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
});
|
||||
}
|
||||
let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?;
|
||||
let action = group_action(drag, Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() });
|
||||
self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal)
|
||||
if window && matches!(proposal.placement, Placement::Split(_)) {
|
||||
return None;
|
||||
}
|
||||
valid(proposal)
|
||||
}
|
||||
|
||||
/// Release of a group drag: join or split at the target, or float at the ghost with the
|
||||
@@ -117,11 +128,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ghost outline at the pointer and one destination preview.
|
||||
/// Ghost outline at the pointer (a window moving live is its own ghost) and one
|
||||
/// destination preview.
|
||||
fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
|
||||
let painter = self.preview_painter(ctx);
|
||||
let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect());
|
||||
painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
if drag.gesture != GroupGesture::Window {
|
||||
let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect());
|
||||
painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
}
|
||||
if float_only {
|
||||
return;
|
||||
}
|
||||
@@ -132,9 +146,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
return;
|
||||
}
|
||||
if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) {
|
||||
let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center);
|
||||
let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center);
|
||||
let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT };
|
||||
painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside);
|
||||
if crate::docking::targets::usable(proposal.preview) {
|
||||
painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside);
|
||||
}
|
||||
if let Some(marker) = proposal.insertion {
|
||||
painter.rect_filled(marker, 0.0, self.style.accent);
|
||||
}
|
||||
@@ -166,7 +182,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let float_only = float_only(ctx);
|
||||
let float_only = float_only(ctx) || drag.undock;
|
||||
let floatable = self.permissions(&drag.panel).floatable
|
||||
&& (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel)));
|
||||
let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer));
|
||||
@@ -189,7 +205,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) };
|
||||
let proposal = if float_only {
|
||||
None
|
||||
} else {
|
||||
self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| resolve_drop(&drag.panel, pointer, &self.targets))
|
||||
};
|
||||
if released {
|
||||
cancel_drag::<P>(ctx, self.id);
|
||||
if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) {
|
||||
@@ -218,7 +238,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
// Reordering inside the source strip shows only the insertion line.
|
||||
let same_group = proposal.insertion.is_some()
|
||||
&& self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel));
|
||||
if !same_group && !proposal.noop {
|
||||
if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) {
|
||||
painter.rect(
|
||||
proposal.preview.shrink(1.0),
|
||||
0.0,
|
||||
|
||||
@@ -45,12 +45,20 @@ pub struct DockStyle {
|
||||
/// Painted under every floating window, so it stays opaque when the panel colours are not.
|
||||
pub floating_background: Color32,
|
||||
pub floating_stroke: Stroke,
|
||||
/// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with
|
||||
/// Maximize, Ctrl/Cmd-drag undock, splits on all four outer edges and frame menu labels.
|
||||
pub profile: Profile,
|
||||
/// Height of a floating window's title bar.
|
||||
pub title_height: f32,
|
||||
/// Width of the band along the layout's outer edge that splits at the root.
|
||||
pub edge_band: f32,
|
||||
/// [D] Height of the bar above each dock column. It drags the column and holds its »/« button.
|
||||
pub column_bar_height: f32,
|
||||
/// [D] Height of the band around the seam between two stacked groups.
|
||||
/// [D] Height of the band around the seam between two stacked groups, in docks and
|
||||
/// floating windows.
|
||||
pub seam_band: f32,
|
||||
/// [D] Width of the band along the window's left and right edges that makes a new dock.
|
||||
pub edge_band: f32,
|
||||
/// [D] Width of the band along the docked area's left and right edges that makes a new dock.
|
||||
pub new_dock_band: f32,
|
||||
/// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide
|
||||
/// layout direction, so the host sets this from its locale. Frame splits keep their sides.
|
||||
pub right_to_left: bool,
|
||||
@@ -58,6 +66,25 @@ pub struct DockStyle {
|
||||
pub move_column_label: String,
|
||||
pub collapse_to_icons_label: String,
|
||||
pub expand_panels_label: String,
|
||||
pub move_window_label: String,
|
||||
pub close_window_label: String,
|
||||
/// Frame profile menu labels.
|
||||
pub undock_label: String,
|
||||
pub undock_group_label: String,
|
||||
pub close_panel_group_label: String,
|
||||
pub close_others_label: String,
|
||||
pub maximize_label: String,
|
||||
pub restore_frame_label: String,
|
||||
/// Shown where native windows are available (feature `native-windows`).
|
||||
pub new_window_label: String,
|
||||
}
|
||||
|
||||
/// Which arrangement a [`DockStyle`] renders and labels.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Profile {
|
||||
#[default]
|
||||
Dock,
|
||||
Frame,
|
||||
}
|
||||
|
||||
impl DockStyle {
|
||||
@@ -90,21 +117,45 @@ impl DockStyle {
|
||||
drag_bar_width: 40.0,
|
||||
floating_background: visuals.window_fill,
|
||||
floating_stroke: visuals.window_stroke,
|
||||
profile: Profile::Dock,
|
||||
title_height: super::chrome::TITLE_HEIGHT,
|
||||
edge_band: 8.0,
|
||||
column_bar_height: 14.0,
|
||||
seam_band: 8.0,
|
||||
edge_band: 12.0,
|
||||
new_dock_band: 12.0,
|
||||
right_to_left: false,
|
||||
move_column_label: "Move panel column".into(),
|
||||
collapse_to_icons_label: "Collapse to Icons".into(),
|
||||
expand_panels_label: "Expand Panels".into(),
|
||||
move_window_label: "Move panel window".into(),
|
||||
close_window_label: "Close panel window".into(),
|
||||
undock_label: "Undock Panel".into(),
|
||||
undock_group_label: "Undock Panel Group".into(),
|
||||
close_panel_group_label: "Close Panel Group".into(),
|
||||
close_others_label: "Close Other Panels in Group".into(),
|
||||
maximize_label: "Maximize Frame".into(),
|
||||
restore_frame_label: "Restore Frame Size".into(),
|
||||
new_window_label: "Move Panel to New Window".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn valid(&self) -> bool {
|
||||
[self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width]
|
||||
.iter()
|
||||
.chain(&[self.column_bar_height, self.seam_band, self.edge_band])
|
||||
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
|
||||
[
|
||||
self.tab_height,
|
||||
self.gap,
|
||||
self.min_pane,
|
||||
self.border.width,
|
||||
self.font.size,
|
||||
self.drag_bar_width,
|
||||
self.floating_stroke.width,
|
||||
self.title_height,
|
||||
self.edge_band,
|
||||
self.column_bar_height,
|
||||
self.seam_band,
|
||||
self.new_dock_band,
|
||||
]
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
|
||||
&& self.tab_height > 0.0
|
||||
&& self.font.size > 0.0
|
||||
}
|
||||
@@ -119,14 +170,41 @@ pub struct DockOutput<P> {
|
||||
pub group_drag: Option<P>,
|
||||
/// Whole-group release for host-owned atomic docking policy.
|
||||
pub group_drop: Option<(P, Pos2)>,
|
||||
/// Requests that are not layout actions.
|
||||
pub events: Vec<DockEvent<P>>,
|
||||
}
|
||||
|
||||
impl<P> Default for DockOutput<P> {
|
||||
fn default() -> Self {
|
||||
Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None }
|
||||
Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A request for host state outside the layout.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum DockEvent<P> {
|
||||
/// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one
|
||||
/// passed to [`DockArea::maximized`].
|
||||
ToggleMaximize { panel: P },
|
||||
}
|
||||
|
||||
/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay.
|
||||
pub enum HidePanels<'a, P> {
|
||||
None,
|
||||
All,
|
||||
/// Every panel except those for which the function returns true.
|
||||
Except(&'a dyn Fn(&P) -> bool),
|
||||
}
|
||||
|
||||
impl<P> Clone for HidePanels<'_, P> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<P> Copy for HidePanels<'_, P> {}
|
||||
|
||||
/// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing
|
||||
/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -160,6 +238,8 @@ pub(super) struct Drag<P> {
|
||||
pub(super) origin: Pos2,
|
||||
pub(super) source: Rect,
|
||||
pub(super) grab: Vec2,
|
||||
/// Ctrl/Cmd held at the press of a frame-profile tab drag: the drop always floats.
|
||||
pub(super) undock: bool,
|
||||
}
|
||||
|
||||
/// What a group gesture moves.
|
||||
@@ -194,7 +274,7 @@ pub fn begin_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id,
|
||||
return false;
|
||||
}
|
||||
let grab = (origin - source_rect.min).clamp(Vec2::ZERO, source_rect.size());
|
||||
ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab }));
|
||||
ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab, undock: false }));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -362,15 +442,31 @@ impl<P, C: DockContent<P> + ?Sized> DockContent<P> for Overrides<'_, P, C> {
|
||||
|
||||
/// Optional renderer for applications without an existing docking surface. Apps with custom
|
||||
/// chrome can instead use the model, target resolver and resize/tab primitives independently.
|
||||
pub struct DockArea<P = ()> {
|
||||
pub struct DockArea<'a, P = ()> {
|
||||
id: Id,
|
||||
activation: Option<P>,
|
||||
blockers: Vec<Rect>,
|
||||
maximized: Option<P>,
|
||||
hide: HidePanels<'a, P>,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|
||||
impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
|
||||
pub fn new(id: Id) -> Self {
|
||||
Self { id, activation: None, blockers: Vec::new() }
|
||||
Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None }
|
||||
}
|
||||
|
||||
/// Maximize Frame: the docked frame holding `panel` fills the area; other frames and
|
||||
/// floating windows do not render or take input. A floating or absent panel is ignored.
|
||||
pub fn maximized(mut self, panel: Option<P>) -> Self {
|
||||
self.maximized = panel;
|
||||
self
|
||||
}
|
||||
|
||||
/// Hide panels (Tab, Shift+Tab); their space goes to the panels that stay. Protected
|
||||
/// panels always stay. The layout is unchanged.
|
||||
pub fn hide_panels(mut self, hide: HidePanels<'a, P>) -> Self {
|
||||
self.hide = hide;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether keyboard focus belongs to a rendered docking control in this layout.
|
||||
@@ -389,10 +485,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|
||||
};
|
||||
for floating in &layout.floating {
|
||||
if floating.groups.iter().any(|g| group(&g.panels))
|
||||
|| floating
|
||||
.panels()
|
||||
.next()
|
||||
.is_some_and(|first| RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))))
|
||||
|| floating.panels().next().is_some_and(|first| {
|
||||
RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name)))
|
||||
|| matches(self.id.with(("move-group", Some(first))).with("close"))
|
||||
})
|
||||
|| floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first()))))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -452,7 +549,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|
||||
|
||||
/// Render a validated snapshot and return requested actions for the app's command handler.
|
||||
/// No action is applied here. Panel contents get a global stable egui ID across moves.
|
||||
/// Floating groups are egui areas inside this viewport; this does not create native windows.
|
||||
/// Floating windows are egui areas inside this viewport; with feature `native-windows`, a
|
||||
/// window with a native rect gets its own OS window where the backend has viewports.
|
||||
pub fn show(
|
||||
self,
|
||||
ui: &mut Ui,
|
||||
@@ -508,6 +606,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|
||||
cancel_drag::<P>(ui.ctx(), self.id);
|
||||
return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() };
|
||||
}
|
||||
let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content);
|
||||
let mut renderer = Renderer {
|
||||
id: self.id,
|
||||
style,
|
||||
@@ -520,12 +619,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|
||||
dock_targets: Vec::new(),
|
||||
owned_interactions: Vec::new(),
|
||||
output: DockOutput::default(),
|
||||
frame,
|
||||
};
|
||||
if let Some(root) = &layout.root {
|
||||
if let Some(root) = &view.root {
|
||||
renderer.node(ui, root, rect, &mut Vec::new());
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
}
|
||||
renderer.floating(ui.ctx());
|
||||
renderer.floating(ui.ctx(), &view.floating);
|
||||
renderer.finish_group_drag(ui.ctx());
|
||||
renderer.finish_drag(ui.ctx());
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
|
||||
|
||||
@@ -904,7 +904,7 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() {
|
||||
assert!(out.actions.is_empty());
|
||||
assert!(content.headers >= 3 && content.bodies >= 3);
|
||||
assert!(content.labels.iter().any(|s| s == "Logical 5"));
|
||||
let at = pos2(130.0, 82.0);
|
||||
let at = pos2(130.0, 96.0);
|
||||
custom_frame(
|
||||
&ctx,
|
||||
&layout,
|
||||
@@ -1249,9 +1249,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() {
|
||||
h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))),
|
||||
"custom action must paint: {logical}"
|
||||
);
|
||||
// This sample also has a floating-group grip labeled Move to group.
|
||||
let expected = if logical == "Move to group" { 2 } else { 1 };
|
||||
assert_eq!(h.query_all_by_label(logical).count(), expected, "popup action must retain its own logical accessibility: {logical}");
|
||||
assert_eq!(h.query_all_by_label(logical).count(), 1, "popup action must retain its own logical accessibility: {logical}");
|
||||
assert_eq!(
|
||||
h.query_all_by_label(&format!("Action visual: {logical}")).count(),
|
||||
0,
|
||||
@@ -1368,7 +1366,7 @@ fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() {
|
||||
}
|
||||
ctx.memory_mut(|memory| memory.request_focus(id));
|
||||
frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 26.0]);
|
||||
assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user