diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs new file mode 100644 index 000000000..c3e399667 --- /dev/null +++ b/crates/ui/src/docking/render/dock.rs @@ -0,0 +1,505 @@ +//! [D] The dock profile: side docks of columns, each a column bar above stacked groups. +//! +//! Columns run from the window edge inwards. Every column's inner edge resizes it; the last +//! column's inner edge is the dock's inner edge. Behaviour follows VectorCraft `floating.rs` +//! (column layout, drop geometry) and PhotoCraft `dock.rs` (group heights). + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{GroupKind, Renderer}; +use crate::docking::{ + Action, Column, Group, GroupDrag, GroupGesture, Node, Side, Target, + targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, +}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; + +/// Width of an iconic column without a saved width. +const ICONIC_WIDTH: f32 = 36.0; +/// Widest an iconic column gets by dragging its inner edge. +const ICONIC_MAX: f32 = 240.0; +/// Width of the »/« button at the outer end of a column bar. +const COLLAPSE_WIDTH: f32 = 18.0; + +/// The physical window edge a dock sits on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::docking) enum Edge { + Left, + Right, +} + +/// `Start` is the left edge in a left-to-right layout and the right edge in a right-to-left one. +pub(in crate::docking) fn edge_of(side: Side, right_to_left: bool) -> Edge { + match (side, right_to_left) { + (Side::Start, false) | (Side::End, true) => Edge::Left, + (Side::Start, true) | (Side::End, false) => Edge::Right, + } +} + +fn side_at(edge: Edge, right_to_left: bool) -> Side { + match (edge, right_to_left) { + (Edge::Left, false) | (Edge::Right, true) => Side::Start, + (Edge::Left, true) | (Edge::Right, false) => Side::End, + } +} + +pub(in crate::docking) fn column_width

(column: &Column

) -> f32 { + if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH) } else { column.width } +} + +/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved +/// height, minimum). Minimized groups take `strip`, groups with a saved height keep it, and the +/// rest share what is left; when every open group has a height, the last one takes the rest. +/// Saved heights shrink towards their minimums before the column overflows and scrolls. +pub(in crate::docking) fn group_heights(groups: &[(bool, Option, f32)], strip: f32, room: f32) -> Vec { + let open: Vec = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.0)).collect(); + let mut shared: Vec = open.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| g.1.is_none_or(|h| !h.is_finite()))).collect(); + if shared.is_empty() { + shared.extend(open.last()); + } + let mut heights: Vec = groups + .iter() + .enumerate() + .map(|(i, (minimized, height, min))| { + if *minimized { + strip + } else if shared.contains(&i) { + 0.0 + } else { + height.unwrap_or(*min).max(*min) + } + }) + .collect(); + let mins: Vec = groups.iter().map(|g| if g.0 { strip } else { g.2 }).collect(); + let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum(); + let deficit = heights.iter().sum::() + shared_min - room; + let slack: f32 = open.iter().filter(|i| !shared.contains(i)).filter_map(|i| Some(heights.get(*i)? - mins.get(*i)?)).sum(); + if deficit > 0.0 && slack > 0.0 { + let take = deficit.min(slack) / slack; + for i in open.iter().filter(|i| !shared.contains(i)) { + if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) { + *height -= (*height - min) * take; + } + } + } + let free = (room - heights.iter().sum::()).max(0.0); + let share = free / shared.len().max(1) as f32; + for i in &shared { + if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) { + *height = share.max(*min); + } + } + heights +} + +impl Renderer<'_, P> { + /// A horizontal split with a dock child: the dock takes its columns' width on its physical + /// edge, the sibling the rest. False when neither child is a dock. + pub(super) fn dock_split(&mut self, ui: &mut Ui, first: &Node

, second: &Node

, rect: Rect, path: &mut Vec) -> bool { + let (dock_first, side, columns, other) = match (first, second) { + (Node::Dock { side, columns }, other) => (true, *side, columns, other), + (other, Node::Dock { side, columns }) => (false, *side, columns, other), + _ => return false, + }; + let gap = self.style.gap; + let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width()); + let width = columns.iter().map(column_width).sum::().clamp(0.0, room); + let (dock, rest) = match edge_of(side, self.style.right_to_left) { + Edge::Left => ( + Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())), + Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max), + ), + Edge::Right => ( + Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max), + Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())), + ), + }; + self.dock(ui, side, columns, dock, room); + path.push(dock_first); + self.node(ui, other, rest, path); + path.pop(); + true + } + + /// One dock in `rect`, columns from its outer edge. `room` is the widest the dock may get. + pub(super) fn dock(&mut self, ui: &mut Ui, side: Side, columns: &[Column

], rect: Rect, room: f32) { + let edge = edge_of(side, self.style.right_to_left); + let (outer, inward) = match edge { + Edge::Left => (rect.left(), 1.0), + Edge::Right => (rect.right(), -1.0), + }; + if columns.is_empty() { + let (band, line) = column_edge(outer, inward, rect.y_range()); + let band = band.union(Rect::from_x_y_ranges( + outer.min(outer + inward * self.style.edge_band)..=outer.max(outer + inward * self.style.edge_band), + rect.y_range(), + )); + self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); + return; + } + if usable(rect) { + ui.painter().rect_filled(rect, 0.0, self.style.background); + } + let total: f32 = columns.iter().map(column_width).sum(); + let mut x = outer; + let mut drawn = Vec::new(); + for (index, column) in columns.iter().enumerate() { + let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; + let width = column_width(column).min((rect.width() - (x - outer).abs()).max(0.0)); + let inner = x + inward * width; + let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); + self.column(ui, column, first, column_rect, edge); + if index == 0 { + let (band, line) = column_edge(x, inward, rect.y_range()); + self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: false }, band, line }); + } + let (band, line) = column_edge(inner, -inward, rect.y_range()); + self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line }); + drawn.push((column, first, width, inner)); + x = inner; + } + // Registered after the columns, so the narrow hit areas win at the edges. + for (column, first, width, inner) in drawn { + let others = total - column_width(column); + let (lo, hi) = if column.iconic { (ICONIC_WIDTH, ICONIC_MAX) } else { self.column_bounds(column) }; + let hi = hi.min(room - others).max(lo.min(room - others).max(0.0)); + let lo = lo.min(hi); + let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range()); + let response = ResizeHandle::new( + self.id.with(("column-resize", Some(first))), + SplitAxis::Horizontal, + divider, + &self.style.resize_label, + self.handle_style(), + ) + .value(width, lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + let next = (width + inward * response.delta).clamp(lo, hi); + if response.delta != 0.0 && next != width { + self.output.actions.push(if column.iconic { + Action::ResizeIconic { panel: first.clone(), width: Some(next) } + } else { + Action::ResizeColumn { panel: first.clone(), width: next } + }); + } + } + } + + fn handle_style(&self) -> ResizeHandleStyle { + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent } + } + + /// Width bounds of an expanded column from its panels' limits. + fn column_bounds(&self, column: &Column

) -> (f32, f32) { + let mut lo = self.style.min_pane; + let mut hi = f32::INFINITY; + for panel in column.groups.iter().flat_map(|g| g.panels.iter()) { + let limits = self.limits(panel); + lo = lo.max(limits.min.x); + hi = hi.min(limits.max.x); + } + (lo, hi.max(lo)) + } + + /// Least height of an open group: its strip and the tallest minimum of its panels. + fn group_min(&self, group: &Group

) -> f32 { + let body = group.panels.iter().map(|p| self.limits(p).min.y).fold(self.style.min_pane, f32::max); + self.style.tab_height + body + } + + fn column(&mut self, ui: &mut Ui, column: &Column

, first: &P, rect: Rect, edge: Edge) { + if !usable(rect) { + return; + } + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); + self.column_bar(ui, column, first, bar, rect, edge); + let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + if column.iconic || !usable(body) { + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + return; + } + let sizes: Vec<_> = column.groups.iter().map(|g| (g.minimized, g.height, self.group_min(g))).collect(); + let heights = group_heights(&sizes, self.style.tab_height, body.height()); + let total: f32 = heights.iter().sum(); + let start = self.targets.len(); + if total > body.height() + 0.5 { + let mut child = + ui.new_child(UiBuilder::new().id_salt(("dock-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(body.intersect(ui.clip_rect())); + egui::ScrollArea::vertical() + .id_salt(self.id.with(("column-scroll", first))) + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL) + .show(&mut child, |ui| { + let (content, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover()); + self.column_groups(ui, column, &heights, content, body); + }); + } else { + self.column_groups(ui, column, &heights, body, body); + } + // A group scrolled out of view takes nothing. + for target in self.targets.iter_mut().skip(start) { + target.rect = target.rect.intersect(body); + target.tab_strip = target.tab_strip.map(|strip| strip.intersect(body)); + for (_, tab) in &mut target.tabs { + *tab = tab.intersect(body); + } + } + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + } + + /// Groups top to bottom in `area` (the scrolled content when the column scrolls), their + /// seams and bands clipped to the visible `view`, and the height dividers. + fn column_groups(&mut self, ui: &mut Ui, column: &Column

, heights: &[f32], area: Rect, view: Rect) { + let mut top = area.top(); + let mut rects = Vec::new(); + for (group, height) in column.groups.iter().zip(heights) { + let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height)); + self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Dock); + rects.push(rect); + top += height; + } + let xs = view.x_range(); + let half = self.style.seam_band * 0.5; + let push = |targets: &mut Vec>, target: DockTarget

, band: Rect, line: Rect| { + let band = band.intersect(view); + if usable(band) { + targets.push(DockDrop { target, band, line: line.intersect(view.expand(2.0)) }); + } + }; + let mut previous: Option<&P> = None; + for (group, rect) in column.groups.iter().zip(&rects) { + let Some(anchor) = group.panels.first() else { continue }; + if let Some(above) = previous { + let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half)); + push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, horizontal_line(rect.top(), xs)); + } + if !group.minimized { + let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + self.style.tab_height).min(rect.bottom())), rect.max); + let quarter = body.height() * 0.25; + let upper = Rect::from_x_y_ranges(xs, body.top()..=(body.top() + quarter)); + let lower = Rect::from_x_y_ranges(xs, (body.bottom() - quarter)..=body.bottom()); + push(&mut self.dock_targets, DockTarget::GroupBand { anchor: anchor.clone(), below: false }, upper, horizontal_line(rect.top(), xs)); + push( + &mut self.dock_targets, + DockTarget::GroupBand { anchor: anchor.clone(), below: true }, + lower, + horizontal_line(rect.bottom(), xs), + ); + } + previous = Some(anchor); + } + // The room under the last group, when every group is minimized, stacks below it. + if let Some(last) = previous + && top < view.bottom() + { + let band = Rect::from_x_y_ranges(xs, top..=view.bottom()); + push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs)); + } + // Height dividers between an open group and the next open one, after the groups so + // their hit areas win at the boundary. Both groups' heights are saved. + let open: Vec = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect(); + for pair in open.windows(2) { + let &[i, j] = pair else { continue }; + let (Some(upper), Some(lower), Some(rect)) = (column.groups.get(i), column.groups.get(j), rects.get(i)) else { continue }; + let (Some(a), Some(b), Some(first), Some(second)) = (heights.get(i), heights.get(j), upper.panels.first(), lower.panels.first()) else { + continue; + }; + let (lo, hi) = (self.group_min(upper), a + b - self.group_min(lower)); + let hi = hi.max(lo); + let divider = Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 1.0)..=(rect.bottom() + 1.0)); + let response = ResizeHandle::new( + self.id.with(("group-resize", Some(first))), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + self.handle_style(), + ) + .value(*a, lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + let next = (a + response.delta).clamp(lo, hi); + if response.delta != 0.0 && next != *a { + self.output.actions.push(Action::ResizeGroup { panel: first.clone(), height: Some(next) }); + self.output.actions.push(Action::ResizeGroup { panel: second.clone(), height: Some(a + b - next) }); + } + } + } + + /// The column bar: the »/« button at its outer end, the rest drags the column (S6), + /// toggles iconic on double-click (M2) and opens the column menu (U1). + fn column_bar(&mut self, ui: &mut Ui, column: &Column

, first: &P, bar: Rect, rect: Rect, edge: Edge) { + ui.painter().rect_filled(bar, 0.0, self.style.tab_background); + let width = COLLAPSE_WIDTH.min(bar.width()); + let (button, grip) = match edge { + Edge::Left => ( + Rect::from_min_max(bar.min, pos2(bar.left() + width, bar.bottom())), + Rect::from_min_max(pos2(bar.left() + width, bar.top()), bar.max), + ), + Edge::Right => ( + Rect::from_min_max(pos2(bar.right() - width, bar.top()), bar.max), + Rect::from_min_max(bar.min, pos2(bar.right() - width, bar.bottom())), + ), + }; + let label = if column.iconic { self.style.expand_panels_label.clone() } else { self.style.collapse_to_icons_label.clone() }; + if usable(button) { + let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click()); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &label)); + // The arrows point where the column goes: towards the window edge when it collapses. + let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" }; + let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text }; + ui.painter().with_clip_rect(button).text(button.center(), egui::Align2::CENTER_CENTER, glyph, self.style.font.clone(), color); + if response.clicked() { + self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); + } + } + if !usable(grip) { + return; + } + let movable = column.groups.iter().flat_map(|g| g.panels.iter()).all(|p| self.permissions(p).movable); + let response = ui.interact(grip, self.id.with(("column-bar", Some(first))), Sense::click_and_drag()); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_column_label)); + if movable && response.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Column, + members: column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), + origin, + source: rect, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + if response.double_clicked() { + self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); + } + for k in 0..2 { + let y = grip.center().y - 1.5 + k as f32 * 3.0; + let x = grip.center().x; + ui.painter().with_clip_rect(grip).line_segment([pos2(x - 9.0, y), pos2(x + 9.0, y)], Stroke::new(1.0, self.style.inactive_text)); + } + if response.has_focus() || response.hovered() { + ui.painter().with_clip_rect(grip).rect_stroke( + grip.shrink(1.0), + 0.0, + Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)), + StrokeKind::Inside, + ); + } + let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space)); + let command = if response.secondary_clicked() || keyboard { + Some(egui::SetOpenCommand::Bool(true)) + } else if response.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + egui::Popup::menu(&response).open_memory(command).show(|ui| { + if self.menu_action(ui, &label) { + self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); + } + }); + } + + /// Whether a floating window (other than the one following the pointer) or a host overlay + /// covers `pointer`. Covered dock targets take nothing. + fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool { + self.blockers.iter().any(|r| r.contains(pointer)) + || self.layout.floating.iter().any(|window| { + let Some(first) = window.panels().next() else { return false }; + !moving.is_some_and(|p| window.contains(p)) + && ctx.memory(|m| m.area_rect(self.id.with(("floating", first)))).is_some_and(|r| r.contains(pointer)) + }) + } + + /// New docks are offered only where the dock profile is in use: in a layout with a dock. + fn has_dock(&self) -> bool { + self.layout.dock_path(Side::Start).is_some() || self.layout.dock_path(Side::End).is_some() + } + + /// Dock targets, with the new-dock bands along the docked area's left and right edges that + /// have no dock yet. + fn dock_candidates(&self) -> Vec> { + let mut candidates = self.dock_targets.clone(); + if !self.has_dock() || !usable(self.workspace) { + return candidates; + } + let rtl = self.style.right_to_left; + let ys = self.workspace.y_range(); + for edge in [Edge::Left, Edge::Right] { + let side = side_at(edge, rtl); + if self.layout.dock_path(side).is_some() { + continue; + } + let (x, inward) = match edge { + Edge::Left => (self.workspace.left(), self.style.edge_band), + Edge::Right => (self.workspace.right(), -self.style.edge_band), + }; + let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys); + let line = vertical_line(x + inward.signum() * 2.0, ys); + candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); + } + candidates + } + + /// The dock target a whole group or column drag would take at `pointer`, with the action + /// and its line. A target that leaves the layout unchanged is a no-op: `None` action. + pub(super) fn dock_group_proposal(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { + let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel); + if self.dock_covered(ctx, pointer, moving) { + return None; + } + let candidates = self.dock_candidates(); + let drop = resolve_dock(pointer, &candidates)?; + let action = group_action(drag, drop.target.to()); + self.checked(action).map(|action| (action, drop.line)) + } + + /// The dock target a single tab drag would take: a new group or column of that panel. A new + /// dock only takes a panel that is alone in its group. + pub(super) fn dock_tab_proposal(&self, ctx: &Context, panel: &P, pointer: Pos2) -> Option<(Option>, Rect)> { + if self.dock_covered(ctx, pointer, None) { + return None; + } + let candidates = self.dock_candidates(); + let drop = resolve_dock(pointer, &candidates)?; + let action = match drop.target.to() { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to @ Target::NewDock { .. } + if self.layout.place(panel).and_then(|place| self.layout.group(&place)).is_some_and(|g| g.panels.len() == 1) => + { + Action::MoveGroup { panel: panel.clone(), to } + } + Target::NewDock { .. } | Target::Float { .. } => return None, + }; + self.checked(action).map(|action| (action, drop.line)) + } + + /// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid. + fn checked(&self, action: Action

) -> Option>> { + let mut candidate = self.layout.clone(); + candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).ok()?; + Some((candidate != *self.layout).then_some(action)) + } + + /// The insertion line of a dock target. + pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) { + if usable(line) { + self.preview_painter(ctx).rect_filled(line, 1.0, self.style.accent); + } + } +} + +/// The move a group gesture makes: the column for a column bar, else the group. +pub(super) fn group_action(drag: &GroupDrag

, to: Target

) -> Action

{ + match drag.gesture { + GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to }, + GroupGesture::Window | GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to }, + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 3b80dfb3e..3c5d33ce6 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -251,6 +251,9 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); } } + if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) { + self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); + } if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { @@ -281,7 +284,7 @@ impl Renderer<'_, P> { /// A stock menu button with shaped text and its logical accessibility label; true when /// clicked, after closing the menu. - fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + pub(super) fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), logical)); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 7539a1c0c..3b16feed0 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -6,6 +6,7 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; +mod dock; mod floating; pub(in crate::docking) use group::GroupKind; @@ -24,6 +25,8 @@ pub(super) struct Renderer<'a, P> { /// The docked area, for host drop requests. pub(super) workspace: Rect, pub(super) targets: Vec>, + /// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`]. + pub(super) dock_targets: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, } @@ -63,6 +66,7 @@ impl Renderer<'_, P> { return; } match node { + Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {} Node::Split { axis, size, first, second } => { let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); @@ -93,17 +97,7 @@ impl Renderer<'_, P> { } } Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), - Node::Dock { columns, .. } => { - // Interim rendering: columns side by side, groups stacked evenly. - let total: f32 = columns.iter().map(|c| c.width).sum(); - let mut left = rect.left(); - for column in columns { - let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; - let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); - left += width; - self.stacked_groups(ui, &column.groups, column_rect, GroupKind::Dock); - } - } + Node::Dock { side, columns } => self.dock(ui, *side, columns, rect, rect.width()), Node::Stack { entries } => { let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); let remaining = (rect.height() - total_header).max(0.0); @@ -220,7 +214,10 @@ pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { match node { - Node::Dock { .. } => PanelLimits { min: Vec2::splat(style.min_pane), ..PanelLimits::default() }, + Node::Dock { columns, .. } => { + let width: f32 = columns.iter().map(dock::column_width).sum(); + PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() } + } Node::Tabs { panels, .. } => { let mut limits = PanelLimits::default(); for panel in panels { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index de2aa4452..010401de8 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; -use super::{Renderer, clamp_float}; +use super::{Renderer, clamp_float, dock::group_action}; use crate::docking::{ Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, }; @@ -32,7 +32,7 @@ impl Renderer<'_, P> { && (down || released) && let Some(pointer) = pointer && !self.blockers.iter().any(|r| r.contains(pointer)) - && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture == GroupGesture::Group)) + && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture != GroupGesture::Window)) { if released { ctx.data_mut(|d| d.remove::>(key)); @@ -59,11 +59,11 @@ impl Renderer<'_, P> { let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)), - GroupGesture::Group => self.drop_group(ctx, &drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); - if drag.gesture == GroupGesture::Group { + if drag.gesture != GroupGesture::Window { self.paint_group_drag(ctx, &drag, pointer, float_only); } } @@ -83,10 +83,7 @@ impl Renderer<'_, P> { }); } let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; - let action = Action::MoveGroup { - panel: drag.panel.clone(), - to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }, - }; + let action = group_action(drag, Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }); self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal) } @@ -97,11 +94,15 @@ impl Renderer<'_, P> { if pointer.distance(drag.origin) < 2.0 { return; } + if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) { + self.output.actions.extend(action); + return; + } let floating = matches!(self.layout.place(&drag.panel), Some(Place::Floating { .. })); let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; - self.output.actions.push(Action::MoveGroup { panel: drag.panel.clone(), to }); + self.output.actions.push(group_action(drag, to)); return; } if proposal.is_some() && !floating { @@ -110,8 +111,7 @@ impl Renderer<'_, P> { // No target: hosts that still apply their own policy for a whole-group release get it. self.output.group_drop = Some((drag.panel.clone(), pointer)); let rect = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); - let action = - Action::MoveGroup { panel: drag.panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) } }; + let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { self.output.actions.push(action); } @@ -125,6 +125,12 @@ impl Renderer<'_, P> { if float_only { return; } + if let Some((action, line)) = self.dock_group_proposal(ctx, drag, pointer) { + if action.is_some() { + self.paint_dock_line(ctx, line); + } + return; + } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center); let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; @@ -170,6 +176,19 @@ impl Renderer<'_, P> { } return; } + let dock = if float_only || gone { None } else { self.dock_tab_proposal(ctx, &drag.panel, pointer) }; + if let Some((action, line)) = dock { + if released { + cancel_drag::

(ctx, self.id); + if let Some(action) = action.filter(|_| pointer.distance(drag.origin) >= 2.0) { + self.output.actions.push(action); + self.output.focused = Some(drag.panel); + } + } else if action.is_some() { + self.paint_dock_line(ctx, line); + } + return; + } let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) }; if released { cancel_drag::

(ctx, self.id); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 901fc2731..d721c44d6 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -45,6 +45,19 @@ pub struct DockStyle { /// Painted under every floating window, so it stays opaque when the panel colours are not. pub floating_background: Color32, pub floating_stroke: Stroke, + /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. + pub column_bar_height: f32, + /// [D] Height of the band around the seam between two stacked groups. + pub seam_band: f32, + /// [D] Width of the band along the window's left and right edges that makes a new dock. + pub edge_band: f32, + /// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide + /// layout direction, so the host sets this from its locale. Frame splits keep their sides. + pub right_to_left: bool, + /// [D] Accessibility label of a column bar. + pub move_column_label: String, + pub collapse_to_icons_label: String, + pub expand_panels_label: String, } impl DockStyle { @@ -77,12 +90,20 @@ impl DockStyle { drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, + column_bar_height: 14.0, + seam_band: 8.0, + edge_band: 12.0, + right_to_left: false, + move_column_label: "Move panel column".into(), + collapse_to_icons_label: "Collapse to Icons".into(), + expand_panels_label: "Expand Panels".into(), } } pub(super) fn valid(&self) -> bool { [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width] .iter() + .chain(&[self.column_bar_height, self.seam_band, self.edge_band]) .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 @@ -148,6 +169,8 @@ pub(super) enum GroupGesture { Window, /// A drag bar or top border: a ghost follows the pointer, the release picks the target. Group, + /// [D] A column bar: like `Group`, for the whole dock column. + Column, } #[derive(Clone)] @@ -378,7 +401,16 @@ impl DockArea

{ while let Some((node, path)) = nodes.pop() { match node { Node::Tabs { panels, .. } if group(panels) => return true, - Node::Dock { columns, .. } if columns.iter().flat_map(|c| c.groups.iter()).any(|g| group(&g.panels)) => return true, + Node::Dock { columns, .. } + if columns.iter().flat_map(|c| c.groups.iter()).any(|g| { + group(&g.panels) + || ["column-bar", "column-collapse", "column-resize", "group-resize"] + .iter() + .any(|name| matches(self.id.with((*name, g.panels.first())))) + }) => + { + return true; + } Node::Stack { entries } => { if entries.iter().any(|entry| { matches(self.id.with(("tab", &entry.panel))) @@ -485,6 +517,7 @@ impl DockArea

{ layout, workspace: rect, targets: Vec::new(), + dock_targets: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), }; diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index a4c8b6318..cd370e5ab 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,6 +1,6 @@ -use egui::{Pos2, Rect, pos2}; +use egui::{Pos2, Rangef, Rect, pos2}; -use super::{Placement, Zone}; +use super::{Placement, Side, Target, Zone}; /// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip /// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas. @@ -149,3 +149,75 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg noop: false, }) } + +/// [D] A dock drop target, built once per frame next to the [`DropTarget`]s. The pointer selects +/// it inside `band`; the preview is the line `line`. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct DockDrop

{ + pub(super) target: DockTarget

, + pub(super) band: Rect, + pub(super) line: Rect, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum DockTarget

{ + /// Column edge: a new column beside the column of `anchor`, further from the window edge + /// when `after` (P5). + ColumnEdge { anchor: P, after: bool }, + /// The seam between two stacked groups: a new group below the group of `anchor` (P4). + Seam { anchor: P }, + /// The top or bottom quarter of a group body, or the free room under a column's last group (P3). + GroupBand { anchor: P, below: bool }, + /// A window edge without a dock, or an empty dock (P5, P6). + NewDock { side: Side }, +} + +impl DockTarget

{ + pub(super) fn to(&self) -> Target

{ + match self { + Self::ColumnEdge { anchor, after } => Target::At { anchor: anchor.clone(), placement: Placement::Column { after: *after } }, + Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true } }, + Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below } }, + Self::NewDock { side } => Target::NewDock { side: *side }, + } + } + + /// Rank in resolution order: column edges reach past their columns, so they come first. + fn rank(&self) -> u8 { + match self { + Self::ColumnEdge { .. } => 0, + Self::Seam { .. } => 1, + Self::GroupBand { .. } => 2, + Self::NewDock { .. } => 3, + } + } +} + +/// The dock target under the pointer: column edges, then seams, then group bands, then new docks. +pub(super) fn resolve_dock(pointer: Pos2, targets: &[DockDrop

]) -> Option<&DockDrop

> { + if !pointer.is_finite() { + return None; + } + targets.iter().filter(|t| usable(t.band) && t.band.contains(pointer)).min_by_key(|t| t.target.rank()) +} + +/// Width of the insertion lines dock targets preview with. +const LINE: f32 = 4.0; +/// How far a column edge band reaches into the column, and past the edge out of it. +const EDGE_IN: f32 = 6.0; +const EDGE_OUT: f32 = 10.0; + +/// Band and line of a column edge at `x`. `inward` is +1 when the column lies right of the edge, +/// -1 when it lies left of it. The band reaches further out of the column than into it. +pub(super) fn column_edge(x: f32, inward: f32, ys: Rangef) -> (Rect, Rect) { + let (a, b) = (x + inward * EDGE_IN, x - inward * EDGE_OUT); + (Rect::from_x_y_ranges(a.min(b)..=a.max(b), ys), vertical_line(x, ys)) +} + +pub(super) fn vertical_line(x: f32, ys: Rangef) -> Rect { + Rect::from_x_y_ranges((x - LINE * 0.5)..=(x + LINE * 0.5), ys) +} + +pub(super) fn horizontal_line(y: f32, xs: Rangef) -> Rect { + Rect::from_x_y_ranges(xs, (y - LINE * 0.5)..=(y + LINE * 0.5)) +}