panes: task-pane host for document apps

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.
This commit is contained in:
aiongg committed 2026-10-19 05:36:18 +00:00
1 parent 87e6562a5e
commit 484717644c
3 files changed
+1041

No files matched your search

+1
View File
@@ -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;
+732
View File
@@ -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<f32>,
/// `[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<P>(&mut self, request: &PaneRequest<P>) {
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<P> {
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<P> PaneRequest<P> {
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<P> {
/// 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<PaneRequest<P>>,
/// The pane holding keyboard focus or last clicked, if it is still shown.
pub focused: Option<P>,
/// 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<P> {
/// 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<f32> {
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<Frame>,
margin: Margin,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Side {
Left,
Right,
Bottom,
}
#[derive(Clone, Copy, Debug, Default)]
struct Memory {
active: Option<Id>,
last_focus: Option<Id>,
/// F6 pressed or [`focus_next`] called: `true` cycles backwards.
cycle: Option<bool>,
drag_out: Option<DragOut>,
}
#[derive(Clone, Copy, Debug)]
struct DragOut {
pane: Id,
cancelled: bool,
}
struct Shown<P> {
pane: P,
id: Id,
state: PaneState,
}
/// Where a pane was drawn, for focus tracking.
struct Region {
pane: Id,
rect: Rect,
floating: Option<LayerId>,
}
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<Id>, reverse: bool) {
let key = memory_id(id.into());
ctx.data_mut(|data| data.get_temp_mut_or_default::<Memory>(key).cycle = Some(reverse));
ctx.request_repaint();
}
impl PaneHost {
pub fn new(id: impl Into<Id>) -> 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<P: Hash + Debug>(&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<Edge> {
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<P: Clone + Hash + Debug, C: PaneContent<P>>(
self,
ui: &mut Ui,
open: &[P],
states: impl Fn(&P) -> Option<PaneState>,
content: &mut C,
) -> PaneOutput<P> {
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::<Memory>(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<Shown<P>> = 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::<PaneState>(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(), &regions, &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<LayerId>, pos: Pos2| -> Option<Option<Id>> {
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<P, C: PaneContent<P>>(&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<P: Clone + Hash + Debug, C: PaneContent<P>>(
&self,
ui: &mut Ui,
s: &Shown<P>,
edge: Edge,
content: &mut C,
style: &ChromeStyle,
focus: bool,
bounds: Rect,
memory: &mut Memory,
preview: &egui::Painter,
requests: &mut Vec<PaneRequest<P>>,
) -> 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<P: Clone>(
&self,
ctx: &Context,
bar: &Response,
s: &Shown<P>,
edge: Edge,
rect: Rect,
bounds: Rect,
memory: &mut Memory,
preview: &egui::Painter,
style: &ChromeStyle,
requests: &mut Vec<PaneRequest<P>>,
) {
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<P: Clone + Hash + Debug, C: PaneContent<P>>(
&self,
ctx: &Context,
s: &Shown<P>,
content: &mut C,
style: &ChromeStyle,
focus: bool,
bounds: Rect,
requests: &mut Vec<PaneRequest<P>>,
) -> (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<P: Clone, C: PaneContent<P>>(
&self,
ui: &mut Ui,
rect: Rect,
s: &Shown<P>,
content: &mut C,
style: &ChromeStyle,
focus: bool,
bounds: Rect,
requests: &mut Vec<PaneRequest<P>>,
) -> 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;
+308
View File
@@ -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<Pane> 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<Pane>,
states: HashMap<Pane, PaneState>,
log: Vec<PaneRequest<Pane>>,
focused: Option<Pane>,
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<Rect> {
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<Event>) {
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<PaneRequest<Pane>> {
app.log.iter().filter(|r| !matches!(r, PaneRequest::Focus(_))).cloned().collect()
}
fn last_resize(app: &App, pane: Pane) -> Option<f32> {
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::<PaneState>(&json).unwrap(), state);
}