mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 14:02:16 +00:00
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:
1 parent
87e6562a5e
commit
484717644c
3 files changed
+1041
No files matched your search
@@ -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;
|
||||
@@ -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(), ®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<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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user