From 484717644c4271b291f2ad60fea32c2a6b20ff60 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:36:18 +0000 Subject: [PATCH] panes: task-pane host for document apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaneHost draws the open panes as egui side and bottom panels before the app's CentralPanel, or as floating frames, each with a title, a ▾ menu (app items, Move to Start/End, Float, Close), × and a resize edge. Edges are logical (Start/End) with an RTL flag. Columns shows every pane side by side; OneSlot shows one closable pane per edge. The app owns open state and pane state: the host returns Close, Place, Resize, FloatRect and Focus requests, the focused pane, and cycles focus with F6. Drag-out-to-float and edge snap follow wordcraft#2584. --- crates/ui/src/lib.rs | 1 + crates/ui/src/panes/mod.rs | 732 +++++++++++++++++++++++++++++++++++ crates/ui/src/panes/tests.rs | 308 +++++++++++++++ 3 files changed, 1041 insertions(+) create mode 100644 crates/ui/src/panes/mod.rs create mode 100644 crates/ui/src/panes/tests.rs diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index fcb608299..f54d1bbc1 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -9,6 +9,7 @@ pub mod buttons; pub mod docking; pub mod icons; pub mod layout; +pub mod panes; pub mod printing; pub mod tabs; pub mod theme; diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs new file mode 100644 index 000000000..c90fb9f43 --- /dev/null +++ b/crates/ui/src/panes/mod.rs @@ -0,0 +1,732 @@ +//! Task panes for document apps: side and bottom panes around the app's own central document. +//! +//! [`PaneHost`] draws each open pane as an egui side or bottom panel, or as a floating frame, with +//! a title bar (title, ▾ menu, ×) and a resizable inner edge. Call it before the app's +//! `CentralPanel`; the document never becomes a panel. The app owns which panes are open and +//! where they are: the host takes the open list and each pane's [`PaneState`] every frame and +//! returns [`PaneRequest`]s for the app to run through its own commands. Docking chrome +//! ([`crate::docking::chrome`]) draws the floating frames and title bars. + +use std::fmt::Debug; +use std::hash::Hash; +use std::ops::RangeInclusive; + +use egui::{ + Color32, Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, + vec2, +}; + +use crate::docking::PanelLimits; +use crate::docking::chrome::{self, ChromeStyle, FloatingFrame, MoveStep, TitleBar}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A title dragged this far out of a docked pane floats it; a pane dropped this close to the +/// host's left or right edge docks there. +pub const DOCK_SNAP: f32 = 32.0; +/// Height of a pane's title bar. +pub const TITLE_BAR_HEIGHT: f32 = 24.0; +/// Size of a pane floated for the first time. +pub const FLOAT_DEFAULT: Vec2 = vec2(270.0, 480.0); +const FLOAT_MIN_HEIGHT: f32 = 160.0; +/// Where the title bar is held when a dragged-out pane floats at the pointer. +const FLOAT_GRAB: Vec2 = vec2(40.0, 12.0); + +/// A logical edge of the host area. `Start` is left in left-to-right interfaces and right in +/// right-to-left ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Edge { + Start, + End, + Bottom, +} + +/// Where a pane is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Place { + Docked(Edge), + Floating, +} + +/// The persisted layout of one pane. The app stores it where it keeps its preferences. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct PaneState { + pub place: Place, + /// Width of a side pane or height of a bottom pane, in points. `None`: the pane's default. + pub size: Option, + /// `[x, y, width, height]` of the floating pane, also kept while it is docked. + pub float_rect: Option<[f32; 4]>, +} + +impl PaneState { + pub fn docked(edge: Edge, size: f32) -> Self { + Self { place: Place::Docked(edge), size: Some(size), float_rect: None } + } + + fn apply

(&mut self, request: &PaneRequest

) { + match request { + PaneRequest::Place(_, place) => self.place = *place, + PaneRequest::Resize(_, size) => self.size = Some(*size), + PaneRequest::FloatRect(_, rect) => self.float_rect = Some(*rect), + PaneRequest::Close(_) | PaneRequest::Focus(_) => {} + } + } +} + +/// How docked panes share an edge. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SidePolicy { + /// Every pane on an edge is shown, side by side in `open` order, the first outermost. + #[default] + Columns, + /// One pane per edge: the last of the edge's closable panes in `open`. Panes that are not + /// closable (context panes the app shows from its own state) are shown beside the slot. + OneSlot, +} + +/// A change the user asked for. The host applies none of them to the app's state. +#[derive(Clone, Debug, PartialEq)] +pub enum PaneRequest

{ + Close(P), + Place(P, Place), + Resize(P, f32), + FloatRect(P, [f32; 4]), + /// The pane took focus from a click, keyboard focus or F6. + Focus(P), +} + +impl

PaneRequest

{ + pub fn pane(&self) -> &P { + match self { + Self::Close(p) | Self::Place(p, _) | Self::Resize(p, _) | Self::FloatRect(p, _) | Self::Focus(p) => p, + } + } +} + +/// What [`PaneHost::show`] returns. +#[derive(Clone, Debug, PartialEq)] +pub struct PaneOutput

{ + /// In the order they happened; apply them in order. Resize and FloatRect arrive every + /// frame a gesture changes the size or rect. + pub requests: Vec>, + /// The pane holding keyboard focus or last clicked, if it is still shown. + pub focused: Option

, + /// F6 moved focus past the last pane (or Shift+F6 past the first): focus the document. + pub to_document: bool, +} + +/// The app's side of a pane: its contents and defaults. +pub trait PaneContent

{ + /// May follow the selection (Format Shape, Format Picture). + fn title(&self, pane: &P) -> String; + fn body(&mut self, ui: &mut Ui, pane: &P); + /// Used when the state callback returns `None` and the host has none in memory. + fn default_state(&self, pane: &P) -> PaneState; + /// Width (or bottom height) limits of the docked pane. + fn size_range(&self, _pane: &P) -> RangeInclusive { + 200.0..=520.0 + } + /// `false`: no Move or Float items and no title drag. + fn movable(&self, _pane: &P) -> bool { + true + } + /// `false`: no × and no Close item (context panes such as PivotTable Fields). + fn closable(&self, _pane: &P) -> bool { + true + } + /// App items, shown above the Move, Float and Close items of the ▾ menu. + fn menu(&mut self, _ui: &mut Ui, _pane: &P) {} +} + +/// Labels of the chrome; translate them in the app. +#[derive(Clone, Debug)] +pub struct PaneLabels { + pub menu: String, + pub move_start: String, + pub move_end: String, + pub move_bottom: String, + pub float: String, + pub close: String, + /// Accessibility label of the title bar. + pub move_pane: String, + /// Accessibility label of the resize edges. + pub resize: String, +} + +impl Default for PaneLabels { + fn default() -> Self { + Self { + menu: "Pane options".into(), + move_start: "Move to Start".into(), + move_end: "Move to End".into(), + move_bottom: "Move to Bottom".into(), + float: "Float".into(), + close: "Close".into(), + move_pane: "Move pane".into(), + resize: "Resize pane".into(), + } + } +} + +/// Draws the open panes. Build it every frame. +pub struct PaneHost { + id: Id, + rtl: bool, + policy: SidePolicy, + max_fraction: f32, + f6: bool, + labels: PaneLabels, + frame: Option, + margin: Margin, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Side { + Left, + Right, + Bottom, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Memory { + active: Option, + last_focus: Option, + /// F6 pressed or [`focus_next`] called: `true` cycles backwards. + cycle: Option, + drag_out: Option, +} + +#[derive(Clone, Copy, Debug)] +struct DragOut { + pane: Id, + cancelled: bool, +} + +struct Shown

{ + pane: P, + id: Id, + state: PaneState, +} + +/// Where a pane was drawn, for focus tracking. +struct Region { + pane: Id, + rect: Rect, + floating: Option, +} + +fn memory_id(id: Id) -> Id { + id.with("panes-memory") +} + +/// Move focus to the next pane (the previous one with `reverse`) at the host's next frame. For +/// apps that route F6 through their own commands; see [`PaneHost::f6`]. +pub fn focus_next(ctx: &Context, id: impl Into, reverse: bool) { + let key = memory_id(id.into()); + ctx.data_mut(|data| data.get_temp_mut_or_default::(key).cycle = Some(reverse)); + ctx.request_repaint(); +} + +impl PaneHost { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + rtl: false, + policy: SidePolicy::Columns, + max_fraction: 1.0, + f6: true, + labels: PaneLabels::default(), + frame: None, + margin: Margin::same(8), + } + } + + /// Right-to-left interface: `Start` is the right edge. + pub fn rtl(mut self, rtl: bool) -> Self { + self.rtl = rtl; + self + } + + pub fn policy(mut self, policy: SidePolicy) -> Self { + self.policy = policy; + self + } + + /// Largest share of the host's width (height for bottom panes) one docked pane may take. + pub fn max_fraction(mut self, fraction: f32) -> Self { + self.max_fraction = if fraction.is_finite() { fraction.clamp(0.0, 1.0) } else { 1.0 }; + self + } + + /// Whether the host takes F6 and Shift+F6 to cycle focus through the panes and back to the + /// document. Default on. Apps that bind F6 themselves call [`focus_next`]. + pub fn f6(mut self, f6: bool) -> Self { + self.f6 = f6; + self + } + + pub fn labels(mut self, labels: PaneLabels) -> Self { + self.labels = labels; + self + } + + /// Frame of the docked panels. Default: the panel fill and a hairline border, no margin. + pub fn frame(mut self, frame: Frame) -> Self { + self.frame = Some(frame); + self + } + + /// Margin around each pane's body. Default 8. + pub fn body_margin(mut self, margin: Margin) -> Self { + self.margin = margin; + self + } + + fn pane_id(&self, pane: &P) -> Id { + self.id.with(("pane", pane)) + } + + fn side(&self, edge: Edge) -> Side { + match (edge, self.rtl) { + (Edge::Bottom, _) => Side::Bottom, + (Edge::Start, false) | (Edge::End, true) => Side::Left, + (Edge::Start, true) | (Edge::End, false) => Side::Right, + } + } + + /// The edge a pane dropped at `pointer` docks against, if it is within [`DOCK_SNAP`] of the + /// host's left or right edge. + fn snap(&self, pointer: Pos2, bounds: Rect) -> Option { + let left = if self.rtl { Edge::End } else { Edge::Start }; + let right = if self.rtl { Edge::Start } else { Edge::End }; + if pointer.x <= bounds.left() + DOCK_SNAP { + Some(left) + } else if pointer.x >= bounds.right() - DOCK_SNAP { + Some(right) + } else { + None + } + } + + /// Draw the panes in `open` with the state `states` returns for each (`None`: the state the + /// host kept from earlier requests, else the pane's default). Call between the app's top + /// and bottom bars and its `CentralPanel`. + pub fn show>( + self, + ui: &mut Ui, + open: &[P], + states: impl Fn(&P) -> Option, + content: &mut C, + ) -> PaneOutput

{ + let ctx = ui.ctx().clone(); + let bounds = ui.available_rect_before_wrap(); + let style = ChromeStyle::from_ui(ui); + let key = memory_id(self.id); + let mut memory = ctx.data(|data| data.get_temp::(key)).unwrap_or_default(); + let mut requests = Vec::new(); + let mut to_document = false; + + if self.f6 { + ui.input_mut(|input| { + if input.consume_key(Modifiers::SHIFT, Key::F6) { + memory.cycle = Some(true); + } else if input.consume_key(Modifiers::NONE, Key::F6) { + memory.cycle = Some(false); + } + }); + } + + let mut shown: Vec> = Vec::new(); + for pane in open { + let id = self.pane_id(pane); + if shown.iter().any(|s| s.id == id) { + continue; + } + let state = + states(pane).or_else(|| ctx.data(|data| data.get_temp::(id.with("state")))).unwrap_or_else(|| content.default_state(pane)); + shown.push(Shown { pane: pane.clone(), id, state }); + } + if self.policy == SidePolicy::OneSlot { + let mut taken = Vec::new(); + for index in (0..shown.len()).rev() { + if let Place::Docked(edge) = shown[index].state.place + && content.closable(&shown[index].pane) + { + if taken.contains(&edge) { + shown.remove(index); + } else { + taken.push(edge); + } + } + } + } + + let previous = memory.active.filter(|active| shown.iter().any(|s| s.id == *active)); + memory.active = previous; + let mut focus_target = None; + if let Some(reverse) = memory.cycle.take() { + let current = memory.active.and_then(|active| shown.iter().position(|s| s.id == active)); + let next = match (current, reverse) { + (None, false) => shown.first(), + (None, true) => shown.last(), + (Some(i), false) => shown.get(i + 1), + (Some(i), true) => i.checked_sub(1).and_then(|i| shown.get(i)), + }; + focus_target = next.map(|s| s.id); + memory.active = focus_target; + if focus_target.is_none() { + to_document = true; + ctx.memory_mut(|m| { + if let Some(focused) = m.focused() { + m.surrender_focus(focused); + } + }); + } + } + + let preview = ctx.layer_painter(LayerId::new(Order::Tooltip, self.id.with("drop-preview"))); + let mut regions = Vec::new(); + for edge in [Edge::Start, Edge::End, Edge::Bottom] { + for s in shown.iter().filter(|s| s.state.place == Place::Docked(edge)) { + let rect = self.docked(ui, s, edge, content, &style, focus_target == Some(s.id), bounds, &mut memory, &preview, &mut requests); + regions.push(Region { pane: s.id, rect, floating: None }); + } + } + for s in shown.iter().filter(|s| s.state.place == Place::Floating) { + let (rect, layer) = self.floating(&ctx, s, content, &style, focus_target == Some(s.id), bounds, &mut requests); + regions.push(Region { pane: s.id, rect, floating: Some(layer) }); + } + + if focus_target.is_none() && !to_document { + self.track_focus(&ctx, ui.layer_id(), ®ions, &mut memory); + } + memory.last_focus = ctx.memory(|m| m.focused()); + if memory.active != previous + && let Some(s) = memory.active.and_then(|active| shown.iter().find(|s| s.id == active)) + { + requests.push(PaneRequest::Focus(s.pane.clone())); + } + + for s in &shown { + let mut state = s.state; + let mut changed = false; + for request in requests.iter().filter(|r| self.pane_id(r.pane()) == s.id) { + state.apply(request); + changed = true; + } + if changed { + ctx.data_mut(|data| data.insert_temp(s.id.with("state"), state)); + } + } + let focused = memory.active.and_then(|active| shown.iter().find(|s| s.id == active)).map(|s| s.pane.clone()); + ctx.data_mut(|data| data.insert_temp(key, memory)); + PaneOutput { requests, focused, to_document } + } + + /// The pane under a primary press, or the pane holding a newly focused widget, becomes active. + /// Presses and focus outside every pane (the document) clear it; presses on other layers + /// (menus, the app's own windows) leave it. + fn track_focus(&self, ctx: &Context, layer: LayerId, regions: &[Region], memory: &mut Memory) { + let find = |at_layer: Option, pos: Pos2| -> Option> { + if let Some(region) = regions.iter().find(|r| r.floating.is_some() && r.floating == at_layer) { + return Some(Some(region.pane)); + } + if at_layer.is_none() || at_layer == Some(layer) { + return Some(regions.iter().find(|r| r.floating.is_none() && r.rect.contains(pos)).map(|r| r.pane)); + } + None + }; + if ctx.input(|input| input.pointer.primary_pressed()) + && let Some(pos) = ctx.input(|input| input.pointer.press_origin()) + && let Some(active) = find(ctx.layer_id_at(pos), pos) + { + memory.active = active; + } + let focused = ctx.memory(|m| m.focused()); + if focused != memory.last_focus + && let Some(response) = focused.and_then(|id| ctx.read_response(id)) + && let Some(active) = find(Some(response.layer_id), response.rect.center()) + { + memory.active = active; + } + } + + fn limits>(&self, content: &C, pane: &P, extent: f32) -> (f32, f32) { + let range = content.size_range(pane); + let min = if range.start().is_finite() { range.start().max(0.0) } else { 0.0 }; + let max = if range.end().is_finite() { range.end().max(min) } else { min.max(extent) }; + (min, max.min(self.max_fraction * extent).max(min)) + } + + #[allow(clippy::too_many_arguments)] + fn docked>( + &self, + ui: &mut Ui, + s: &Shown

, + edge: Edge, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + memory: &mut Memory, + preview: &egui::Painter, + requests: &mut Vec>, + ) -> Rect { + let side = self.side(edge); + let extent = if side == Side::Bottom { bounds.height() } else { bounds.width() }; + let (min, max) = self.limits(content, &s.pane, extent); + let default = content.default_state(&s.pane).size; + let size = s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(min).clamp(min, max); + let panel_id = s.id.with("panel"); + let panel = match side { + Side::Left => egui::Panel::left(panel_id), + Side::Right => egui::Panel::right(panel_id), + Side::Bottom => egui::Panel::bottom(panel_id), + }; + let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke)); + let shown = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| { + let top = ui.max_rect(); + let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT)); + ui.allocate_rect(title, Sense::hover()); + let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); + Frame::NONE.inner_margin(self.margin).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + content.body(ui, &s.pane); + }); + bar + }); + let rect = shown.response.rect; + let bar = shown.inner; + + let (axis, strip) = match side { + Side::Left => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.right() - 2.0..=rect.right() + 2.0, rect.y_range())), + Side::Right => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.left() - 2.0..=rect.left() + 2.0, rect.y_range())), + Side::Bottom => (SplitAxis::Vertical, Rect::from_x_y_ranges(rect.x_range(), rect.top() - 2.0..=rect.top() + 2.0)), + }; + let handle_style = ResizeHandleStyle { hovered: style.accent.gamma_multiply(0.5), dragged: style.accent, focused: style.accent }; + let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(ui); + let next = if resize.response.dragged() { + resize.response.interact_pointer_pos().map(|p| match side { + Side::Left => p.x - rect.left(), + Side::Right => rect.right() - p.x, + Side::Bottom => rect.bottom() - p.y, + }) + } else if resize.delta != 0.0 { + // Arrow keys move the edge: right grows a left pane, left grows a right pane. + Some(if side == Side::Left { size + resize.delta } else { size - resize.delta }) + } else { + None + }; + if let Some(next) = next.filter(|v| v.is_finite()).map(|v| v.clamp(min, max).round()) + && next != size + { + requests.push(PaneRequest::Resize(s.pane.clone(), next)); + } + + if content.movable(&s.pane) { + self.drag_out(ui.ctx(), &bar, s, edge, rect, bounds, memory, preview, style, requests); + } + rect + } + + /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped near the host's + /// left or right edge it docks there, elsewhere it floats under the pointer. + #[allow(clippy::too_many_arguments)] + fn drag_out( + &self, + ctx: &Context, + bar: &Response, + s: &Shown

, + edge: Edge, + rect: Rect, + bounds: Rect, + memory: &mut Memory, + preview: &egui::Painter, + style: &ChromeStyle, + requests: &mut Vec>, + ) { + if bar.drag_started() { + memory.drag_out = Some(DragOut { pane: s.id, cancelled: false }); + } + let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; + if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + drag.cancelled = true; + } + let near = rect.expand(DOCK_SNAP); + let Some(pointer) = ctx.pointer_latest_pos() else { return }; + let outside = if edge == Edge::Bottom { !near.y_range().contains(pointer.y) } else { !near.x_range().contains(pointer.x) }; + let size = s.state.float_rect.map_or(FLOAT_DEFAULT, |r| vec2(r[2], r[3])); + let float = float_rect(Some(rect_array(Rect::from_min_size(pointer - FLOAT_GRAB, size))), bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); + let target = self.snap(pointer, bounds); + if bar.drag_stopped() { + memory.drag_out = None; + if drag.cancelled || !outside { + return; + } + match target { + Some(target) if target != edge => requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(target))), + Some(_) => {} + None => { + requests.push(PaneRequest::Place(s.pane.clone(), Place::Floating)); + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(float))); + } + } + return; + } + if !bar.dragged() { + memory.drag_out = None; + return; + } + memory.drag_out = Some(drag); + if drag.cancelled || !outside { + return; + } + let shape = match target.map(|t| self.side(t)) { + Some(Side::Left) => Rect::from_x_y_ranges(bounds.left()..=bounds.left() + rect.width(), bounds.y_range()), + Some(_) => Rect::from_x_y_ranges(bounds.right() - rect.width()..=bounds.right(), bounds.y_range()), + None => float, + }; + paint_preview(preview, shape, style.accent); + } + + #[allow(clippy::too_many_arguments)] + fn floating>( + &self, + ctx: &Context, + s: &Shown

, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + requests: &mut Vec>, + ) -> (Rect, LayerId) { + let (min_width, _) = self.limits(content, &s.pane, bounds.width()); + let min = vec2(min_width, FLOAT_MIN_HEIGHT); + let rect = float_rect(s.state.float_rect, bounds, min); + let limits = PanelLimits { min, max: bounds.size().max(min) }; + let shown = FloatingFrame::new(s.id.with("float"), rect).bounds(bounds).limits(limits).resize_label(&self.labels.resize).show( + ctx, + style, + |ui, rect| { + let title = Rect::from_min_size(rect.min, vec2(rect.width(), TITLE_BAR_HEIGHT)); + let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); + let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max) - self.margin; + if body.is_positive() { + ui.scope_builder(UiBuilder::new().max_rect(body), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + content.body(ui, &s.pane); + }); + } + bar + }, + ); + if let Some(resized) = shown.resized { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(resized))); + } + if content.movable(&s.pane) { + match chrome::live_move(ctx, &shown.inner, shown.rect, bounds) { + Some(MoveStep::Moving(next) | MoveStep::Cancelled(next)) if next != shown.rect => { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(next))); + } + Some(MoveStep::Dropped { rect, pointer }) => { + if let Some(edge) = self.snap(pointer, bounds) { + requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(edge))); + } else if rect != shown.rect { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); + } + } + _ => {} + } + } + (shown.rect, shown.layer) + } + + /// Title, ▾ menu and ×. Returns the bar, which senses the title drag. + #[allow(clippy::too_many_arguments)] + fn chrome>( + &self, + ui: &mut Ui, + rect: Rect, + s: &Shown

, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + requests: &mut Vec>, + ) -> Response { + let movable = content.movable(&s.pane); + let closable = content.closable(&s.pane); + let mut bar = + TitleBar::new(s.id.with("title"), &self.labels.move_pane).title(content.title(&s.pane)).menu(&self.labels.menu).movable(movable); + if closable { + bar = bar.close(&self.labels.close); + } + let title = bar.show(ui, rect, style); + if focus { + title.bar.request_focus(); + } + if title.close.as_ref().is_some_and(Response::clicked) { + requests.push(PaneRequest::Close(s.pane.clone())); + } + if let Some(menu) = &title.menu { + egui::Popup::menu(menu).show(|ui| { + let top = ui.cursor().top(); + content.menu(ui, &s.pane); + if ui.cursor().top() > top && (movable || closable) { + ui.separator(); + } + if movable { + let mut places = vec![(Place::Docked(Edge::Start), &self.labels.move_start), (Place::Docked(Edge::End), &self.labels.move_end)]; + if content.default_state(&s.pane).place == Place::Docked(Edge::Bottom) { + places.push((Place::Docked(Edge::Bottom), &self.labels.move_bottom)); + } + places.push((Place::Floating, &self.labels.float)); + for (place, label) in places { + if ui.selectable_label(s.state.place == place, label.as_str()).clicked() && s.state.place != place { + requests.push(PaneRequest::Place(s.pane.clone(), place)); + if place == Place::Floating { + let rect = float_rect(s.state.float_rect, bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); + } + } + } + } + if closable && ui.button(self.labels.close.as_str()).clicked() { + requests.push(PaneRequest::Close(s.pane.clone())); + } + }); + } + title.bar + } +} + +fn paint_preview(painter: &egui::Painter, rect: Rect, accent: Color32) { + painter.rect(rect.shrink(1.0), 0.0, accent.gamma_multiply(0.15), Stroke::new(2.0, accent), StrokeKind::Inside); +} + +fn rect_array(rect: Rect) -> [f32; 4] { + [rect.min.x, rect.min.y, rect.width(), rect.height()] +} + +/// The floating rect for `saved` (`[x, y, width, height]`), at least `min` and at most the +/// bounds in size, inside `bounds`. Without a saved rect: [`FLOAT_DEFAULT`] right of the middle. +/// Non-finite or negative values fall back to the defaults, so the result is always finite. +pub fn float_rect(saved: Option<[f32; 4]>, bounds: Rect, min: Vec2) -> Rect { + let bounds = if bounds.is_finite() && bounds.is_positive() { bounds } else { Rect::from_min_size(Pos2::ZERO, FLOAT_DEFAULT) }; + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let size = saved.map_or(FLOAT_DEFAULT, |r| vec2(finite(r[2], FLOAT_DEFAULT.x), finite(r[3], FLOAT_DEFAULT.y))); + let size = size.max(min.max(Vec2::splat(1.0))).min(bounds.size()); + let free = (bounds.size() - size).max(Vec2::ZERO); + let default = (bounds.min + vec2(free.x * 0.75, free.y * 0.3)).round(); + let pos = saved.map_or(default, |r| pos2(finite(r[0], default.x), finite(r[1], default.y))); + let pos = pos.max(bounds.min).min((bounds.max - size).max(bounds.min)); + Rect::from_min_size(pos, size) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs new file mode 100644 index 000000000..837760be0 --- /dev/null +++ b/crates/ui/src/panes/tests.rs @@ -0,0 +1,308 @@ +//! Behaviour tests for the pane host, driven by egui pointer and key events in an 800 × 600 +//! window. The harness's root ui has an 8 pt margin, so the host spans x 8..792, y 8..592. + +use std::collections::HashMap; + +use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2, Rect, Ui, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +use super::*; + +const L: f32 = 8.0; +const R: f32 = 792.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Pane { + Nav, + Styles, + Comments, + /// A context pane, like PivotTable Fields: not closable, not movable. + Pivot, +} + +struct Content; + +impl PaneContent for Content { + fn title(&self, pane: &Pane) -> String { + format!("{pane:?}") + } + fn body(&mut self, ui: &mut Ui, pane: &Pane) { + ui.label(format!("{pane:?} body")); + } + fn default_state(&self, pane: &Pane) -> PaneState { + let edge = if matches!(pane, Pane::Comments | Pane::Pivot) { Edge::End } else { Edge::Start }; + PaneState::docked(edge, 250.0) + } + fn movable(&self, pane: &Pane) -> bool { + *pane != Pane::Pivot + } + fn closable(&self, pane: &Pane) -> bool { + *pane != Pane::Pivot + } +} + +/// The app side: open list, persisted states and every request it received. +struct App { + open: Vec, + states: HashMap, + log: Vec>, + focused: Option, + to_document: usize, + rtl: bool, + policy: SidePolicy, +} + +impl App { + fn new(open: &[Pane]) -> Self { + Self { open: open.to_vec(), states: HashMap::new(), log: Vec::new(), focused: None, to_document: 0, rtl: false, policy: SidePolicy::Columns } + } + + fn frame(&mut self, ui: &mut Ui) { + let states = self.states.clone(); + let out = PaneHost::new("panes").rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Content); + egui::CentralPanel::default().show(ui, |ui| ui.label("Document")); + for request in &out.requests { + match request { + PaneRequest::Close(p) => self.open.retain(|o| o != p), + PaneRequest::Focus(_) => {} + other => { + let pane = *other.pane(); + let state = self.states.entry(pane).or_insert_with(|| Content.default_state(&pane)); + state.apply(other); + } + } + } + self.log.extend(out.requests); + self.focused = out.focused; + self.to_document += usize::from(out.to_document); + } + + fn place(&self, pane: Pane) -> Place { + self.states.get(&pane).map_or_else(|| Content.default_state(&pane).place, |s| s.place) + } +} + +fn harness(app: App) -> Harness<'static, App> { + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(|ui, app: &mut App| app.frame(ui), app); + h.input_mut().focused = true; + h.run_steps(2); + h +} + +fn pane_id(pane: Pane) -> Id { + Id::new("panes").with(("pane", &pane)) +} + +fn rect(h: &Harness<'_, App>, id: Id) -> Option { + h.ctx.read_response(id).map(|r| r.rect) +} + +fn title(h: &Harness<'_, App>, pane: Pane) -> Rect { + rect(h, pane_id(pane).with("title")).unwrap() +} + +/// The x of a side pane's inner edge. +fn edge(h: &Harness<'_, App>, pane: Pane) -> f32 { + rect(h, pane_id(pane).with("resize")).unwrap().center().x +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn send(h: &mut Harness<'_, App>, events: Vec) { + h.input_mut().events.extend(events); + h.run_steps(1); +} + +fn click(h: &mut Harness<'_, App>, at: Pos2) { + send(h, vec![Event::PointerMoved(at), button(at, true)]); + send(h, vec![button(at, false)]); + h.run_steps(1); +} + +fn hold(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { + send(h, vec![Event::PointerMoved(from), button(from, true)]); + send(h, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + send(h, vec![Event::PointerMoved(to)]); +} + +fn drag(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { + hold(h, from, to); + send(h, vec![button(to, false)]); + h.run_steps(2); +} + +fn key(h: &mut Harness<'_, App>, key: Key, modifiers: Modifiers) { + send(h, vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + h.run_steps(1); +} + +/// The requests that change a pane's state or open list. +fn changes(app: &App) -> Vec> { + app.log.iter().filter(|r| !matches!(r, PaneRequest::Focus(_))).cloned().collect() +} + +fn last_resize(app: &App, pane: Pane) -> Option { + app.log.iter().rev().find_map(|r| match r { + PaneRequest::Resize(p, size) if *p == pane => Some(*size), + _ => None, + }) +} + +#[test] +fn columns_sit_side_by_side_resize_and_move_to_end() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Styles])); + assert_eq!(edge(&h, Pane::Nav), L + 250.0); + assert_eq!(edge(&h, Pane::Styles), L + 500.0); + assert!(title(&h, Pane::Nav).left() < L + 2.0); + + drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0)); + assert_eq!(last_resize(h.state(), Pane::Nav), Some(300.0)); + assert_eq!(edge(&h, Pane::Nav), L + 300.0); + assert_eq!(edge(&h, Pane::Styles), L + 550.0, "the second column follows"); + + let at = rect(&h, pane_id(Pane::Nav).with("title").with("menu")).unwrap().center(); + click(&mut h, at); + for label in ["Move to Start", "Move to End", "Float", "Close"] { + assert!(h.query_all_by_label(label).count() >= 1, "{label}"); + } + h.get_by_label("Move to End").click(); + h.run_steps(3); + assert!(h.state().log.contains(&PaneRequest::Place(Pane::Nav, Place::Docked(Edge::End)))); + assert_eq!(edge(&h, Pane::Nav), R - 300.0, "a 300 pt pane at the right edge"); + assert_eq!(edge(&h, Pane::Styles), L + 250.0); +} + +#[test] +fn title_dragged_out_floats_and_a_drop_near_an_edge_docks() { + // Drag-out-to-float and the 32 pt edge snap follow wordcraft#2584. + let mut h = harness(App::new(&[Pane::Styles])); + let from = title(&h, Pane::Styles).center(); + // Within DOCK_SNAP of the pane: nothing happens. + drag(&mut h, from, pos2(L + 270.0, 300.0)); + assert!(changes(h.state()).is_empty(), "{:?}", h.state().log); + + drag(&mut h, from, pos2(400.0, 300.0)); + assert_eq!( + changes(h.state()), + vec![PaneRequest::Place(Pane::Styles, Place::Floating), PaneRequest::FloatRect(Pane::Styles, [360.0, 112.0, 270.0, 480.0])], + "floats under the pointer, clamped to the window" + ); + assert_eq!(title(&h, Pane::Styles).min, pos2(360.0, 112.0)); + + // Dropped within 32 pt of the right edge: docked at End. + h.state_mut().log.clear(); + let from = title(&h, Pane::Styles).center(); + drag(&mut h, from, pos2(780.0, 300.0)); + assert_eq!(h.state().log.last(), Some(&PaneRequest::Place(Pane::Styles, Place::Docked(Edge::End)))); + assert_eq!(edge(&h, Pane::Styles), R - 250.0); + // The floating rect's size is kept for the next float. + assert_eq!(h.state().states[&Pane::Styles].float_rect.map(|r| [r[2], r[3]]), Some([270.0, 480.0])); +} + +#[test] +fn escape_and_focus_loss_restore_a_floating_pane_during_a_move() { + let mut app = App::new(&[Pane::Styles]); + let start = [300.0, 100.0, 270.0, 400.0]; + app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: None, float_rect: Some(start) }); + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + let float_rect = |h: &Harness<'_, App>| h.state().states[&Pane::Styles].float_rect; + + hold(&mut h, from, from + vec2(40.0, 30.0)); + assert_eq!(float_rect(&h), Some([340.0, 130.0, 270.0, 400.0]), "follows the pointer"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(float_rect(&h), Some(start)); + send(&mut h, vec![Event::PointerMoved(pos2(790.0, 300.0))]); + send(&mut h, vec![button(pos2(790.0, 300.0), false)]); + h.run_steps(1); + assert_eq!(h.state().place(Pane::Styles), Place::Floating, "the rest of the drag is ignored"); + assert_eq!(float_rect(&h), Some(start)); + + hold(&mut h, from, from + vec2(-60.0, 20.0)); + assert_eq!(float_rect(&h), Some([240.0, 120.0, 270.0, 400.0])); + h.input_mut().focused = false; + h.run_steps(1); + h.input_mut().focused = true; + send(&mut h, vec![button(from + vec2(-60.0, 20.0), false)]); + assert_eq!(float_rect(&h), Some(start)); +} + +#[test] +fn f6_cycles_through_the_panes_and_back_to_the_document() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Comments])); + let focused_widget = |h: &Harness<'_, App>| h.ctx.memory(|m| m.focused()); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().focused, Some(Pane::Nav)); + assert_eq!(focused_widget(&h), Some(pane_id(Pane::Nav).with("title"))); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().focused, Some(Pane::Comments)); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!((h.state().focused, h.state().to_document), (None, 1)); + assert_eq!(focused_widget(&h), None); + key(&mut h, Key::F6, Modifiers::SHIFT); + assert_eq!(h.state().focused, Some(Pane::Comments), "Shift+F6 from the document goes to the last pane"); + let focus: Vec<_> = h.state().log.iter().filter(|r| matches!(r, PaneRequest::Focus(_))).cloned().collect(); + assert_eq!(focus, vec![PaneRequest::Focus(Pane::Nav), PaneRequest::Focus(Pane::Comments), PaneRequest::Focus(Pane::Comments)]); + + // A click in the document clears it; a click in a pane sets it. + click(&mut h, pos2(400.0, 300.0)); + assert_eq!(h.state().focused, None); + click(&mut h, pos2(100.0, 300.0)); + assert_eq!(h.state().focused, Some(Pane::Nav)); +} + +#[test] +fn one_slot_shows_the_last_pane_per_edge_beside_context_panes() { + let mut app = App::new(&[Pane::Pivot, Pane::Comments, Pane::Styles]); + app.policy = SidePolicy::OneSlot; + app.states.insert(Pane::Styles, PaneState::docked(Edge::End, 300.0)); + let mut h = harness(app); + assert!(rect(&h, pane_id(Pane::Comments).with("title")).is_none()); + assert_eq!(edge(&h, Pane::Pivot), R - 250.0, "the context pane stays, outermost"); + assert_eq!(edge(&h, Pane::Styles), R - 550.0); + assert!(rect(&h, pane_id(Pane::Pivot).with("title").with("close")).is_none()); + + let at = rect(&h, pane_id(Pane::Styles).with("title").with("close")).unwrap().center(); + click(&mut h, at); + h.run_steps(1); + assert_eq!(h.state().open, vec![Pane::Pivot, Pane::Comments]); + assert_eq!(edge(&h, Pane::Comments), R - 500.0, "the slot shows the remaining pane"); +} + +#[test] +fn rtl_mirrors_sides_and_edge_snaps() { + let mut app = App::new(&[Pane::Nav, Pane::Comments]); + app.rtl = true; + let mut h = harness(app); + assert_eq!(edge(&h, Pane::Nav), R - 250.0, "Start is the right edge"); + assert_eq!(edge(&h, Pane::Comments), L + 250.0); + + let from = title(&h, Pane::Nav).center(); + drag(&mut h, from, pos2(10.0, 300.0)); + assert_eq!(changes(h.state()), vec![PaneRequest::Place(Pane::Nav, Place::Docked(Edge::End))], "the left edge is End"); + // Both on End, outermost first in open order. + assert_eq!(edge(&h, Pane::Nav), L + 250.0); + assert_eq!(edge(&h, Pane::Comments), L + 500.0); +} + +#[test] +fn float_rect_is_finite_and_inside_the_bounds() { + let bounds = Rect::from_min_size(pos2(0.0, 100.0), vec2(800.0, 500.0)); + let min = vec2(200.0, 160.0); + assert_eq!(float_rect(Some([300.0, 150.0, 280.0, 300.0]), bounds, min), Rect::from_min_size(pos2(300.0, 150.0), vec2(280.0, 300.0))); + for junk in [[f32::NAN, 0.0, 1.0, -3.0], [5000.0, -5000.0, 1.0e9, 1.0e9], [f32::NEG_INFINITY, f32::INFINITY, f32::NAN, 0.0]] { + let r = float_rect(Some(junk), bounds, min); + assert!(r.is_finite() && bounds.contains_rect(r) && r.width() >= 200.0, "{junk:?} -> {r:?}"); + } + assert_eq!(float_rect(None, bounds, min).size(), FLOAT_DEFAULT.min(bounds.size())); +} + +#[cfg(feature = "serde")] +#[test] +fn pane_state_round_trips_through_json() { + let state = PaneState { place: Place::Docked(Edge::End), size: Some(280.0), float_rect: Some([1.0, 2.0, 270.0, 480.0]) }; + let json = serde_json::to_string(&state).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), state); +}