mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 17:26:55 +00:00
Remove `Profile`. What it switched is now a `DockStyle` setting with the dock-and-canvas behaviour as the default: `side_edge_zones`, `new_dock_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, and plain bools for `wheel_selects_tabs` and `middle_click_closes_tabs`. The undock and Close Panel Group labels go; an app sets `float_label`, `float_group_label` and `close_group_label` instead. Remove the size machinery that made frames stick: `DockContent::bare`, `FixedSlot` and the split that took a parent's or sibling's half beside a fixed-size frame, and the gap snapping to the nearest frame. A tab group takes the largest maximum of its panels, so a panel without one lifts the cap of the group it joins. A dragged divider stores a ratio whatever mode it was saved with. Sizes follow the reference rules: a divider resizes only the groups that touch it and stops at their minimums; a four-way handle where a divider ends on another moves both (`Divider::Intersection`); a split or outer-edge drop that would leave a group without `min_pane` is not offered, and a group too small to halve takes the drop as a tab. Tests: one per rule, the stuck cases from the FilmCraft map, and a property test over random drops checking minimums and divider travel.
832 lines
36 KiB
Rust
832 lines
36 KiB
Rust
use std::{fmt::Debug, hash::Hash};
|
||
|
||
use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2};
|
||
|
||
use super::{Action, DockError, DropTarget, Layout, Permissions, render::Renderer, resize::AnchoredResize};
|
||
|
||
/// Horizontal room around a tab's label, both when measuring its natural width and when cutting it.
|
||
pub(super) const TAB_PADDING: f32 = 24.0;
|
||
|
||
/// Application-supplied chrome. Body renderers retain their own UI style and contents.
|
||
#[derive(Clone, Debug)]
|
||
#[non_exhaustive]
|
||
pub struct DockStyle {
|
||
pub tab_height: f32,
|
||
pub gap: f32,
|
||
pub min_pane: f32,
|
||
pub background: Color32,
|
||
pub tab_background: Color32,
|
||
pub active_background: Color32,
|
||
pub text: Color32,
|
||
pub inactive_text: Color32,
|
||
pub border: Stroke,
|
||
pub accent: Color32,
|
||
pub font: FontId,
|
||
/// Localize these action labels in the application adapter.
|
||
pub float_label: String,
|
||
pub close_label: String,
|
||
/// Tab menu item that moves one panel into another group (submenu of target panels).
|
||
pub move_panel_to_label: String,
|
||
pub panels_label: String,
|
||
pub resize_label: String,
|
||
pub resize_window_label: String,
|
||
/// Accessibility label of a group's drag bar and top border.
|
||
pub move_group_label: String,
|
||
pub close_group_label: String,
|
||
pub minimize_label: String,
|
||
pub expand_label: String,
|
||
pub float_group_label: String,
|
||
/// Group menu item that moves the whole group into another group (submenu of targets).
|
||
pub move_group_to_label: String,
|
||
/// Minimum width of the drag bar left of the group header. Tabs overflow before the bar
|
||
/// narrows.
|
||
pub drag_bar_width: f32,
|
||
/// Painted under every floating window, so it stays opaque when the panel colors are not.
|
||
pub floating_background: Color32,
|
||
pub floating_stroke: Stroke,
|
||
/// 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,
|
||
/// The outer edge band also runs along the left and right edges, where a drop adds a
|
||
/// full-height column. Off: only the top and bottom edges add a full-width row.
|
||
pub side_edge_zones: bool,
|
||
/// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new
|
||
/// dock (see [`Self::new_dock_band`]).
|
||
pub new_dock_zones: bool,
|
||
/// [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, in docks and
|
||
/// floating windows.
|
||
pub seam_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] Side of a panel icon in an iconic column; each icon row is 10 pt taller.
|
||
pub icon_size: f32,
|
||
/// [D] Width of an iconic column without a saved width, and the narrowest it gets.
|
||
pub iconic_width: f32,
|
||
/// [D] Widest an iconic column gets by dragging its inner edge.
|
||
pub iconic_max_width: f32,
|
||
/// [D] An iconic column at least this wide shows panel names beside the icons.
|
||
pub iconic_label_width: f32,
|
||
/// [D] Column menu item that toggles Auto-Collapse Iconic Panels.
|
||
pub auto_collapse_label: String,
|
||
/// [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. Splits keep their sides.
|
||
pub right_to_left: bool,
|
||
/// [D] Accessibility label of a column bar.
|
||
pub move_column_label: String,
|
||
/// [D] Column menu and group menu items.
|
||
pub collapse_to_icons_label: String,
|
||
pub expand_panels_label: String,
|
||
/// [D] Accessibility labels of the column bar's »/« button, distinct from the menu items.
|
||
pub collapse_column_label: String,
|
||
pub expand_column_label: String,
|
||
pub move_window_label: String,
|
||
pub close_window_label: String,
|
||
/// Group menu items Close Other Panels in Group, and Maximize Frame / Restore Frame Size
|
||
/// for groups of the split tree, each shown when its flag is on.
|
||
pub close_others_label: String,
|
||
pub close_others_item: bool,
|
||
pub maximize_label: String,
|
||
pub restore_frame_label: String,
|
||
pub maximize_item: bool,
|
||
/// Shown where native windows are available (feature `native-windows`).
|
||
pub new_window_label: String,
|
||
/// Shown under the pointer while a drag offers a docking target, when [`Self::float_hint`]
|
||
/// is on. `{key}` stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl
|
||
/// elsewhere.
|
||
pub float_hint_label: String,
|
||
pub float_hint: bool,
|
||
/// Ctrl/Cmd held when a tab drag of a split-tree group starts makes its drop float, even
|
||
/// when the key is let go during the drag. Off: only the key held at the drop counts.
|
||
pub command_press_floats: bool,
|
||
/// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group,
|
||
/// Close Other Panels in Group) for closable panels. A host that draws its own Close items
|
||
/// turns this off and keeps [`Permissions::closable`], so its close actions still apply.
|
||
pub close_menu_items: bool,
|
||
/// Show × on the title bar of a floating window whose panels are all closable.
|
||
pub close_button: bool,
|
||
/// The mouse wheel over a tab strip selects the next or previous tab, one per notch.
|
||
pub wheel_selects_tabs: bool,
|
||
/// A middle click on a tab closes its panel when the panel is closable.
|
||
pub middle_click_closes_tabs: bool,
|
||
}
|
||
|
||
impl Default for DockStyle {
|
||
/// [`Self::from_visuals`] with egui's default visuals.
|
||
fn default() -> Self {
|
||
Self::from_visuals(&egui::Visuals::default())
|
||
}
|
||
}
|
||
|
||
impl DockStyle {
|
||
/// Colors from the `ui`'s visuals; adjust fields afterwards.
|
||
pub fn from_ui(ui: &Ui) -> Self {
|
||
Self::from_visuals(ui.visuals())
|
||
}
|
||
|
||
pub fn from_visuals(visuals: &egui::Visuals) -> Self {
|
||
Self {
|
||
tab_height: 26.0,
|
||
gap: 4.0,
|
||
min_pane: 40.0,
|
||
background: visuals.panel_fill,
|
||
tab_background: visuals.extreme_bg_color,
|
||
active_background: visuals.widgets.active.bg_fill,
|
||
text: visuals.text_color(),
|
||
inactive_text: visuals.weak_text_color(),
|
||
border: visuals.widgets.noninteractive.bg_stroke,
|
||
accent: visuals.selection.stroke.color,
|
||
font: FontId::proportional(12.0),
|
||
float_label: "Float Panel".into(),
|
||
close_label: "Close Panel".into(),
|
||
move_panel_to_label: "Move Panel to Group".into(),
|
||
panels_label: "Panels".into(),
|
||
resize_label: "Resize panels".into(),
|
||
resize_window_label: "Resize panel window".into(),
|
||
move_group_label: "Move tab group".into(),
|
||
close_group_label: "Close Tab Group".into(),
|
||
minimize_label: "Minimize".into(),
|
||
expand_label: "Expand".into(),
|
||
float_group_label: "Float Tab Group".into(),
|
||
move_group_to_label: "Move Group To".into(),
|
||
drag_bar_width: 40.0,
|
||
floating_background: visuals.window_fill,
|
||
floating_stroke: visuals.window_stroke,
|
||
title_height: super::chrome::TITLE_HEIGHT,
|
||
edge_band: 8.0,
|
||
side_edge_zones: false,
|
||
new_dock_zones: true,
|
||
column_bar_height: 14.0,
|
||
seam_band: 8.0,
|
||
new_dock_band: 12.0,
|
||
icon_size: 20.0,
|
||
iconic_width: 36.0,
|
||
iconic_max_width: 240.0,
|
||
iconic_label_width: 96.0,
|
||
auto_collapse_label: "Auto-Collapse Iconic Panels".into(),
|
||
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(),
|
||
collapse_column_label: "Collapse column to icons".into(),
|
||
expand_column_label: "Expand column".into(),
|
||
move_window_label: "Move panel window".into(),
|
||
close_window_label: "Close panel window".into(),
|
||
close_others_label: "Close Other Panels in Group".into(),
|
||
close_others_item: false,
|
||
maximize_label: "Maximize Frame".into(),
|
||
restore_frame_label: "Restore Frame Size".into(),
|
||
maximize_item: false,
|
||
new_window_label: "Move Panel to New Window".into(),
|
||
float_hint_label: "Hold {key} to undock".into(),
|
||
float_hint: false,
|
||
command_press_floats: false,
|
||
close_menu_items: true,
|
||
close_button: true,
|
||
wheel_selects_tabs: false,
|
||
middle_click_closes_tabs: false,
|
||
}
|
||
}
|
||
|
||
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,
|
||
self.title_height,
|
||
self.edge_band,
|
||
self.column_bar_height,
|
||
self.seam_band,
|
||
self.new_dock_band,
|
||
self.icon_size,
|
||
self.iconic_width,
|
||
self.iconic_max_width,
|
||
self.iconic_label_width,
|
||
]
|
||
.iter()
|
||
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
|
||
&& self.iconic_width <= self.iconic_max_width
|
||
&& self.tab_height > 0.0
|
||
&& self.font.size > 0.0
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct DockOutput<P> {
|
||
pub actions: Vec<Action<P>>,
|
||
pub error: Option<DockError>,
|
||
/// The panel the user focused this frame: a clicked tab or menu entry, a drop, or a press
|
||
/// of any pointer button in a panel body (stripless panels included) or in a group header,
|
||
/// which focuses the group's shown panel. Only user input sets
|
||
/// it; [`DockArea::raise_panel`] and host commands do not.
|
||
pub focused: Option<P>,
|
||
/// Whole-floating-group gesture identity; host may display its own group drop preview.
|
||
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, events: Vec::new() }
|
||
}
|
||
}
|
||
|
||
/// Room between the dock area's top-left corner and a unit a command floats.
|
||
const FLOAT_INSET: f32 = 24.0;
|
||
|
||
/// Size a command floats a panel at when its [`FloatSpace`] never drew it.
|
||
#[cfg_attr(not(feature = "serde"), allow(dead_code))]
|
||
pub(super) const DEFAULT_FLOAT: Vec2 = Vec2::new(260.0, 320.0);
|
||
|
||
/// Where a docking command floats a panel, group or column when it gives no rect (see
|
||
/// `DockCommand::to_actions`, feature `serde`). [`DockArea`] records one each frame; read it
|
||
/// with [`float_space`].
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct FloatSpace<P> {
|
||
/// The rect the area drew in. A floated unit goes inside it, 24 points from its top-left
|
||
/// corner, so it stays clear of the app's menu bar.
|
||
pub area: Rect,
|
||
/// What a drag floats from each drawn panel: the panel alone, its group and its dock column.
|
||
pub(super) panels: Vec<(P, Vec2)>,
|
||
pub(super) groups: Vec<(P, Vec2)>,
|
||
pub(super) columns: Vec<(P, Vec2)>,
|
||
/// The height each panel's content used when it was last drawn, which fits a floated group
|
||
/// to its content; `None` for content that filled its body.
|
||
pub(super) contents: Vec<(P, Option<f32>)>,
|
||
}
|
||
|
||
impl<P: PartialEq> FloatSpace<P> {
|
||
/// An area with no recorded sizes: commands float a panel at 260x320, and a group or
|
||
/// column at its dock column's width (else 260) by 320.
|
||
pub fn new(area: Rect) -> Self {
|
||
Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() }
|
||
}
|
||
|
||
/// Adds the size a command floats `panel` at alone when the area has not drawn it, such as
|
||
/// a hidden panel or a headless run. A size the area recorded for the panel takes precedence.
|
||
pub fn with_panel_size(mut self, panel: P, size: Vec2) -> Self {
|
||
self.panels.push((panel, size));
|
||
self
|
||
}
|
||
}
|
||
|
||
impl<P: PartialEq> FloatSpace<P> {
|
||
/// Record the height `panel`'s content used in a body `available` tall. Content that
|
||
/// reaches the body's bottom filled it and has no height of its own, unless the body was
|
||
/// sized to the height recorded before (a fitted window). `at` is the panel's entry in
|
||
/// `contents` if it has one; returns where the entry is.
|
||
pub(super) fn record_content(&mut self, at: Option<usize>, panel: P, used: f32, available: f32) -> usize {
|
||
const TOLERANCE: f32 = 1.0;
|
||
let used = if used.is_finite() { used.max(0.0) } else { 0.0 };
|
||
let recorded = at.and_then(|i| self.contents.get_mut(i)).filter(|(p, _)| *p == panel);
|
||
let fitted = recorded.as_ref().and_then(|(_, height)| *height).is_some_and(|height| height >= available - TOLERANCE);
|
||
let height = (used < available - TOLERANCE || fitted).then_some(used);
|
||
match (recorded, at) {
|
||
(Some((_, recorded)), Some(at)) => {
|
||
*recorded = height;
|
||
at
|
||
}
|
||
_ => {
|
||
self.contents.push((panel, height));
|
||
self.contents.len() - 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Lookups for `DockCommand`, which needs feature `serde`.
|
||
#[cfg_attr(not(feature = "serde"), allow(dead_code))]
|
||
impl<P: PartialEq> FloatSpace<P> {
|
||
pub(super) fn panel(&self, panel: &P) -> Option<Vec2> {
|
||
Self::find(&self.panels, panel)
|
||
}
|
||
|
||
pub(super) fn group(&self, panel: &P) -> Option<Vec2> {
|
||
Self::find(&self.groups, panel)
|
||
}
|
||
|
||
pub(super) fn column(&self, panel: &P) -> Option<Vec2> {
|
||
Self::find(&self.columns, panel)
|
||
}
|
||
|
||
/// The first record wins: an iconic panel is recorded at its icon before its flyout.
|
||
fn find(sizes: &[(P, Vec2)], panel: &P) -> Option<Vec2> {
|
||
sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size)
|
||
}
|
||
|
||
/// `[x, y, width, height]` of `size` inside the area, inset from its top-left corner and
|
||
/// shrunk to fit.
|
||
pub(super) fn place(&self, size: Vec2) -> [f32; 4] {
|
||
let size = size.min(self.area.size());
|
||
let min = (self.area.min + Vec2::splat(FLOAT_INSET)).min(self.area.max - size).max(self.area.min);
|
||
[min.x, min.y, size.x, size.y]
|
||
}
|
||
}
|
||
|
||
/// The [`FloatSpace`] the area recorded on its last frame.
|
||
pub fn float_space<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) -> Option<FloatSpace<P>> {
|
||
ctx.data(|d| d.get_temp(float_space_key(area_id)))
|
||
}
|
||
|
||
fn float_space_key(area: Id) -> Id {
|
||
area.with("float-space")
|
||
}
|
||
|
||
/// The panel whose tab held keyboard focus at the end of the last frame, where it was, and
|
||
/// whether that frame's own actions may have moved it.
|
||
type TabFocus<P> = (P, super::dock_model::Place, bool);
|
||
|
||
fn tab_focus_key(area: Id) -> Id {
|
||
area.with("tab-focus")
|
||
}
|
||
|
||
/// Keyboard focus stays on a tab only while its panel stays where the user put it: a panel
|
||
/// hidden, closed or moved by the host (a command or a workspace reset) gives focus up.
|
||
fn release_moved_tab_focus<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>) {
|
||
let Some((panel, place, moved_by_area)) = ctx.data(|d| d.get_temp::<TabFocus<P>>(tab_focus_key(area))) else { return };
|
||
let tab = area.with(("tab", &panel));
|
||
let place_now = layout.place(&panel);
|
||
if ctx.memory(|m| m.has_focus(tab)) && (place_now.is_none() || (place_now != Some(place) && !moved_by_area)) {
|
||
ctx.memory_mut(|m| m.surrender_focus(tab));
|
||
}
|
||
}
|
||
|
||
fn record_tab_focus<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>, acted: bool) {
|
||
let focused = ctx.memory(|m| m.focused());
|
||
let tab = focused.and_then(|id| layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == id));
|
||
let record = tab.and_then(|panel| Some((panel.clone(), layout.place(panel)?, acted)));
|
||
ctx.data_mut(|d| match record {
|
||
Some(record) => {
|
||
d.insert_temp(tab_focus_key(area), record);
|
||
}
|
||
None => d.remove::<TabFocus<P>>(tab_focus_key(area)),
|
||
});
|
||
}
|
||
|
||
/// 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 },
|
||
/// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its
|
||
/// preference and passes it to [`DockArea::auto_collapse_iconic`].
|
||
ToggleAutoCollapse,
|
||
/// [D] The area's flyout opened, switched to another panel or closed (`None`), whatever
|
||
/// changed it: an icon, Escape, a press outside, a raised panel, a group that is no longer
|
||
/// iconic, or [`super::open_flyout`] and [`super::close_flyout`]. It comes once per change,
|
||
/// in the frame that draws the change.
|
||
FlyoutChanged { panel: Option<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. A
|
||
/// tab group takes the largest minimum and the largest maximum of its panels, so a panel without
|
||
/// a maximum lifts the maximum of the panels it joins.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct PanelLimits {
|
||
pub min: Vec2,
|
||
pub max: Vec2,
|
||
}
|
||
|
||
impl Default for PanelLimits {
|
||
fn default() -> Self {
|
||
Self { min: Vec2::ZERO, max: Vec2::splat(1_000_000.0) }
|
||
}
|
||
}
|
||
|
||
impl PanelLimits {
|
||
pub(super) fn valid(self) -> bool {
|
||
self.min.is_finite()
|
||
&& self.max.is_finite()
|
||
&& self.min.x >= 0.0
|
||
&& self.min.y >= 0.0
|
||
&& self.max.x >= self.min.x
|
||
&& self.max.y >= self.min.y
|
||
&& self.max.x <= 1_000_000.0
|
||
&& self.max.y <= 1_000_000.0
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub(super) struct Drag<P> {
|
||
pub(super) panel: P,
|
||
pub(super) origin: Pos2,
|
||
pub(super) source: Rect,
|
||
pub(super) grab: Vec2,
|
||
/// Ctrl/Cmd held at the press, with [`DockStyle::command_press_floats`]: the drop floats.
|
||
pub(super) undock: bool,
|
||
}
|
||
|
||
/// What a group gesture moves.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(super) enum GroupGesture {
|
||
/// A floating window's grip: the window follows the pointer.
|
||
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)]
|
||
pub(super) struct GroupDrag<P> {
|
||
/// The first member; identifies the gesture.
|
||
pub(super) panel: P,
|
||
/// The floating window rect at the press, restored when a window move is canceled.
|
||
pub(super) original: [f32; 4],
|
||
pub(super) gesture: GroupGesture,
|
||
pub(super) members: Vec<P>,
|
||
pub(super) origin: Pos2,
|
||
/// The group (or window) rect at the press. A floated group keeps this size.
|
||
pub(super) source: Rect,
|
||
}
|
||
|
||
/// Start a docking drag from an application's existing tab/header. Use the same area ID when
|
||
/// switching from a legacy layout to [`DockArea`]. The model is untouched until a valid release.
|
||
pub fn begin_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id, panel: P, source_rect: Rect) -> bool {
|
||
let Some(origin) = ctx.pointer_interact_pos().filter(|p| p.is_finite()) else { return false };
|
||
if !super::targets::usable(source_rect) {
|
||
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, undock: false }));
|
||
true
|
||
}
|
||
|
||
/// Whether this area's tab or group gesture is active.
|
||
pub fn is_dragging<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) -> bool {
|
||
ctx.data(|data| data.get_temp::<Drag<P>>(area_id.with("drag")).is_some() || data.get_temp::<GroupDrag<P>>(area_id.with("group-drag")).is_some())
|
||
}
|
||
|
||
/// Discard pending tab/group gestures and an actively dragged renderer-owned control before
|
||
/// replacing a workspace. Body/custom-header/menu gestures and other areas are untouched.
|
||
/// Ownership is recorded as controls render; before an initial render or drag threshold there
|
||
/// may be no active egui drag to cancel. Keyboard focus is unchanged. The host owns the
|
||
/// replacement snapshot, so this does not restore a group's previous rectangle.
|
||
pub fn cancel_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) {
|
||
let dragged = ctx.dragged_id();
|
||
let owned = ctx.data_mut(|d| {
|
||
d.remove::<Drag<P>>(area_id.with("drag"));
|
||
d.remove::<GroupDrag<P>>(area_id.with("group-drag"));
|
||
let ids = d.remove_temp::<Vec<Id>>(area_id.with("owned-interactions"));
|
||
let resize = d.get_temp::<Id>(area_id.with("pointer-resize"));
|
||
d.remove::<Id>(area_id.with("pointer-resize"));
|
||
for id in resize.iter().chain(ids.iter().flatten()) {
|
||
d.remove::<AnchoredResize>(id.with("pointer-anchor"));
|
||
}
|
||
dragged.is_some_and(|id| resize == Some(id) || ids.is_some_and(|ids| ids.contains(&id)))
|
||
});
|
||
if owned {
|
||
ctx.stop_dragging();
|
||
}
|
||
}
|
||
|
||
/// One mutable application adapter owns bodies, menus, shaped text and per-panel policy,
|
||
/// avoiding competing mutable closures. Logical labels are used for tab accessibility.
|
||
/// [`DockArea::show_content`] reads every policy from here; the closures passed to the older
|
||
/// `show*` functions override `logical_label`, `permissions` and `limits`.
|
||
pub trait DockContent<P> {
|
||
fn body(&mut self, ui: &mut Ui, panel: &P);
|
||
/// Plain text name for accessibility, menus and the default visual label.
|
||
fn logical_label(&self, panel: &P) -> String
|
||
where
|
||
P: Debug,
|
||
{
|
||
format!("{panel:?}")
|
||
}
|
||
fn permissions(&self, _panel: &P) -> Permissions {
|
||
Permissions::default()
|
||
}
|
||
fn limits(&self, _panel: &P) -> PanelLimits {
|
||
PanelLimits::default()
|
||
}
|
||
/// Preferred floating size, independent of the current dock geometry: the width of a
|
||
/// floated panel and the most height it takes. A floated panel or group, and a flyout, fit
|
||
/// their height to the content the active panel last drew (at least 120 points), up to this
|
||
/// height, or up to 320 out of a dock when this is `None`. Content that filled its body
|
||
/// takes this height, else 320. A rect a command gives and a window the user resized keep
|
||
/// their size.
|
||
fn floating_size(&self, _panel: &P) -> Option<Vec2> {
|
||
None
|
||
}
|
||
/// An image for the panel's icon, tinted with the icon color. [`Self::paint_icon`] goes first.
|
||
fn icon(&self, _panel: &P) -> Option<egui::ImageSource<'static>> {
|
||
None
|
||
}
|
||
/// Paint the panel's icon in code into `rect` with `color`, and return true. False draws
|
||
/// [`Self::icon`], or the label's initial when that is `None`. `painter` is clipped to the
|
||
/// icon's cell.
|
||
fn paint_icon(&mut self, _painter: &egui::Painter, _rect: Rect, _panel: &P, _color: Color32) -> bool {
|
||
false
|
||
}
|
||
/// Whether the panel may move to a native OS window (feature `native-windows`).
|
||
fn allow_native(&self, _panel: &P) -> bool {
|
||
false
|
||
}
|
||
/// Host-owned drop resolution for targets the renderer cannot know about. Returning
|
||
/// feedback consumes the gesture, including no-op targets; the renderer then emits no move.
|
||
fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option<DropFeedback> {
|
||
None
|
||
}
|
||
fn label(&mut self, _ui: &Ui, _panel: &P, logical: &str, _font: &FontId, _color: Color32) -> egui::WidgetText {
|
||
logical.into()
|
||
}
|
||
/// Shape stock Float/Move/Close action text independently of panel identity.
|
||
/// The renderer preserves `logical` for accessibility, even when the visual text differs.
|
||
fn action_label(&mut self, _ui: &Ui, logical: &str, _font: &FontId, _color: Color32) -> egui::WidgetText {
|
||
logical.into()
|
||
}
|
||
fn panel_menu(&mut self, _ui: &mut Ui, _panel: &P) {}
|
||
/// Width of the group header at the strip's end (the host's ≡ and its own tabs).
|
||
/// `strip_width` is the whole tab strip; the header gets at most that, and the drag bar and
|
||
/// the group's tabs share what is left.
|
||
fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool, _strip_width: f32) -> f32 {
|
||
0.0
|
||
}
|
||
/// Draw the group header into `ui.max_rect()`, sized by [`Self::group_header_width`].
|
||
fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {}
|
||
}
|
||
|
||
/// Host-owned drop resolution request, see [`DockContent::resolve_drag`].
|
||
pub struct DropRequest<'a, P> {
|
||
pub panel: &'a P,
|
||
pub pointer: Pos2,
|
||
pub targets: &'a [DropTarget<P>],
|
||
pub floating_rect: Option<Rect>,
|
||
pub whole_group: bool,
|
||
pub released: bool,
|
||
pub workspace: Rect,
|
||
pub screen: Rect,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct DropFeedback {
|
||
pub preview: Option<Rect>,
|
||
pub insertion: Option<Rect>,
|
||
}
|
||
|
||
struct BodyContent<F>(F);
|
||
impl<P, F: FnMut(&mut Ui, &P)> DockContent<P> for BodyContent<F> {
|
||
fn body(&mut self, ui: &mut Ui, panel: &P) {
|
||
(self.0)(ui, panel);
|
||
}
|
||
}
|
||
|
||
/// The older `show*` closures in front of an application's content.
|
||
struct Overrides<'a, P, C: ?Sized> {
|
||
label: &'a dyn Fn(&P) -> String,
|
||
permissions: &'a dyn Fn(&P) -> Permissions,
|
||
limits: &'a dyn Fn(&P) -> PanelLimits,
|
||
inner: &'a mut C,
|
||
}
|
||
|
||
impl<P, C: DockContent<P> + ?Sized> DockContent<P> for Overrides<'_, P, C> {
|
||
fn body(&mut self, ui: &mut Ui, panel: &P) {
|
||
self.inner.body(ui, panel);
|
||
}
|
||
fn logical_label(&self, panel: &P) -> String {
|
||
(self.label)(panel)
|
||
}
|
||
fn permissions(&self, panel: &P) -> Permissions {
|
||
(self.permissions)(panel)
|
||
}
|
||
fn limits(&self, panel: &P) -> PanelLimits {
|
||
(self.limits)(panel)
|
||
}
|
||
fn floating_size(&self, panel: &P) -> Option<Vec2> {
|
||
self.inner.floating_size(panel)
|
||
}
|
||
fn icon(&self, panel: &P) -> Option<egui::ImageSource<'static>> {
|
||
self.inner.icon(panel)
|
||
}
|
||
fn paint_icon(&mut self, painter: &egui::Painter, rect: Rect, panel: &P, color: Color32) -> bool {
|
||
self.inner.paint_icon(painter, rect, panel, color)
|
||
}
|
||
fn allow_native(&self, panel: &P) -> bool {
|
||
self.inner.allow_native(panel)
|
||
}
|
||
fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option<DropFeedback> {
|
||
self.inner.resolve_drag(ctx, style, request)
|
||
}
|
||
fn label(&mut self, ui: &Ui, panel: &P, logical: &str, font: &FontId, color: Color32) -> egui::WidgetText {
|
||
self.inner.label(ui, panel, logical, font, color)
|
||
}
|
||
fn action_label(&mut self, ui: &Ui, logical: &str, font: &FontId, color: Color32) -> egui::WidgetText {
|
||
self.inner.action_label(ui, logical, font, color)
|
||
}
|
||
fn panel_menu(&mut self, ui: &mut Ui, panel: &P) {
|
||
self.inner.panel_menu(ui, panel);
|
||
}
|
||
fn group_header_width(&self, panels: &[P], active: &P, floating: bool, strip_width: f32) -> f32 {
|
||
self.inner.group_header_width(panels, active, floating, strip_width)
|
||
}
|
||
fn group_header(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) {
|
||
self.inner.group_header(ui, panels, active, floating);
|
||
}
|
||
}
|
||
|
||
/// 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<'a, P = ()> {
|
||
id: Id,
|
||
activation: Option<P>,
|
||
blockers: Vec<Rect>,
|
||
maximized: Option<P>,
|
||
hide: HidePanels<'a, P>,
|
||
auto_collapse: bool,
|
||
float_bounds: Option<Rect>,
|
||
}
|
||
|
||
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(), maximized: None, hide: HidePanels::None, auto_collapse: true, float_bounds: None }
|
||
}
|
||
|
||
/// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press
|
||
/// outside it. Off, it stays open until its icon or another icon is clicked.
|
||
pub fn auto_collapse_iconic(mut self, on: bool) -> Self {
|
||
self.auto_collapse = on;
|
||
self
|
||
}
|
||
|
||
/// 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.
|
||
/// Panel bodies and application-provided header/menu widgets are excluded. Hosts may
|
||
/// route nonconflicting global shortcuts through chrome while retaining widget ownership
|
||
/// of activation, traversal, resize and cancellation keys. Invalid layouts return false.
|
||
pub fn chrome_has_focus(&self, ctx: &egui::Context, layout: &Layout<P>) -> bool {
|
||
let Some(focused) = ctx.memory(|memory| memory.focused()) else { return false };
|
||
if layout.validate().is_err() || ctx.read_response(focused).is_none() {
|
||
return false;
|
||
}
|
||
super::hits::named(self.id, layout).contains_key(&focused)
|
||
}
|
||
|
||
/// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate
|
||
/// through the application's command path as well to select its tab. A raise is not user
|
||
/// input: it moves no keyboard focus and is not reported as [`DockOutput::focused`].
|
||
pub fn raise_panel(mut self, panel: P) -> Self {
|
||
self.activation = Some(panel);
|
||
self
|
||
}
|
||
|
||
/// Where floating windows, flyouts and drag ghosts may go (default: the whole viewport).
|
||
/// An app whose own title bar sits under a transparent OS title bar (macOS full-size
|
||
/// content) passes the viewport below that strip: a window drawn there would take presses
|
||
/// that the OS or the app turns into moving the whole app window. Windows outside the
|
||
/// bounds, such as ones restored from a saved layout, are drawn moved inside.
|
||
pub fn float_bounds(mut self, bounds: Rect) -> Self {
|
||
self.float_bounds = Some(bounds);
|
||
self
|
||
}
|
||
|
||
/// Host overlays that must reject docking gestures, including floating fallback.
|
||
pub fn blocked_rects(mut self, rects: &[Rect]) -> Self {
|
||
self.blockers = rects.to_vec();
|
||
self
|
||
}
|
||
|
||
/// 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 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,
|
||
layout: &Layout<P>,
|
||
style: &DockStyle,
|
||
label: impl Fn(&P) -> String,
|
||
permissions: impl Fn(&P) -> Permissions,
|
||
body: impl FnMut(&mut Ui, &P),
|
||
) -> DockOutput<P> {
|
||
self.show_with_limits(ui, layout, style, label, permissions, |_| PanelLimits::default(), body)
|
||
}
|
||
|
||
/// Like [`Self::show`], with per-panel size policies supplied by the application.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn show_with_limits(
|
||
self,
|
||
ui: &mut Ui,
|
||
layout: &Layout<P>,
|
||
style: &DockStyle,
|
||
label: impl Fn(&P) -> String,
|
||
permissions: impl Fn(&P) -> Permissions,
|
||
limits: impl Fn(&P) -> PanelLimits,
|
||
body: impl FnMut(&mut Ui, &P),
|
||
) -> DockOutput<P> {
|
||
self.show_customized(ui, layout, style, label, permissions, limits, &mut BodyContent(body))
|
||
}
|
||
|
||
/// Render with application-owned chrome and shaped labels. Header callbacks queue app
|
||
/// commands; structural changes are applied after rendering, like DockOutput actions.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn show_customized(
|
||
self,
|
||
ui: &mut Ui,
|
||
layout: &Layout<P>,
|
||
style: &DockStyle,
|
||
label: impl Fn(&P) -> String,
|
||
permissions: impl Fn(&P) -> Permissions,
|
||
limits: impl Fn(&P) -> PanelLimits,
|
||
content: &mut impl DockContent<P>,
|
||
) -> DockOutput<P> {
|
||
let mut content = Overrides { label: &label, permissions: &permissions, limits: &limits, inner: content };
|
||
self.show_content(ui, layout, style, &mut content)
|
||
}
|
||
|
||
/// Render with every label, policy and size from `content`.
|
||
pub fn show_content(self, ui: &mut Ui, layout: &Layout<P>, style: &DockStyle, content: &mut dyn DockContent<P>) -> DockOutput<P> {
|
||
let rect = ui.available_rect_before_wrap();
|
||
if let Err(error) = layout.validate() {
|
||
cancel_drag::<P>(ui.ctx(), self.id);
|
||
return DockOutput { error: Some(error), ..Default::default() };
|
||
}
|
||
if !style.valid() || !super::targets::usable(rect) || layout.panels().into_iter().any(|p| !content.limits(p).valid()) {
|
||
cancel_drag::<P>(ui.ctx(), self.id);
|
||
return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() };
|
||
}
|
||
release_moved_tab_focus(ui.ctx(), self.id, layout);
|
||
let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content);
|
||
let places = layout.places();
|
||
// A panel keeps the content height it last drew while hidden or minimized.
|
||
let contents: Vec<(P, Option<f32>)> = float_space::<P>(ui.ctx(), self.id)
|
||
.map(|space| space.contents.into_iter().filter(|(panel, _)| places.contains_key(panel)).collect())
|
||
.unwrap_or_default();
|
||
let content_index = contents.iter().enumerate().map(|(i, (panel, _))| (panel.clone(), i)).collect();
|
||
let mut renderer = Renderer {
|
||
id: self.id,
|
||
style,
|
||
content,
|
||
activation: self.activation,
|
||
blockers: self.blockers,
|
||
layout,
|
||
workspace: rect,
|
||
float_bounds: self.float_bounds.map_or(ui.ctx().content_rect(), |bounds| bounds.intersect(ui.ctx().content_rect())),
|
||
targets: Vec::new(),
|
||
dock_targets: Vec::new(),
|
||
owned_interactions: Vec::new(),
|
||
output: DockOutput::default(),
|
||
frame,
|
||
iconic: super::render::Iconic::new(self.auto_collapse),
|
||
float_space: FloatSpace { contents, ..FloatSpace::new(rect) },
|
||
places,
|
||
contents: content_index,
|
||
dividers: Vec::new(),
|
||
};
|
||
if let Some(root) = &view.root {
|
||
renderer.node(ui, root, rect, &mut Vec::new());
|
||
ui.allocate_rect(rect, Sense::hover());
|
||
}
|
||
renderer.flyout(ui.ctx());
|
||
renderer.floating(ui.ctx(), &view.floating);
|
||
renderer.finish_group_drag(ui);
|
||
renderer.finish_drag(ui.ctx());
|
||
renderer.report_flyout(ui.ctx());
|
||
super::hits::record(ui.ctx(), self.id, layout, &renderer.owned_interactions);
|
||
record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty());
|
||
ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space));
|
||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
|
||
renderer.output
|
||
}
|
||
}
|