mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 03:23:24 +00:00
docking: iconic columns with flyouts, hit rects and a test driver
An iconic column draws one icon per panel, a grip above each group and a separator between groups (M3). Widened past `DockStyle::iconic_label_width` it shows panel names (M4). Clicking an icon opens its group as a flyout on the canvas side; another icon switches, the same icon closes, and with Auto-Collapse Iconic Panels (default on) Escape or a press outside closes it (M5). Dragging a flyout's strip floats its group (M6), an icon drags its panel (S7) and the grip its group (S8). A drop into an iconic column makes a new icon group, previewed by the column outline and an insertion line (P9). `raise_panel` on an iconic panel opens its flyout (U3), and the column menu has the Auto-Collapse toggle as `DockEvent::ToggleAutoCollapse`. Iconic widths move to `DockStyle` (`icon_size`, `iconic_width`, `iconic_max_width`). `DockArea::hit_rects` names every gesture source the area drew last frame, and `docking::testing::DockDriver` (feature `testing`) drives an area with real egui input for application tests. Behaviour follows vectorcraft crates/ui-egui/src/dock.rs (icon column, flyout placement, names when widened) and photocraft dock.rs (auto-collapse).
This commit is contained in:
1 parent
315fde3ed8
commit
0c140eb65b
10 files changed
+1073
-16
No files matched your search
@@ -18,6 +18,8 @@ serde = { version = "1", features = ["derive"], optional = true }
|
||||
serde = ["dep:serde"]
|
||||
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
|
||||
native-windows = []
|
||||
# `docking::testing`: a headless driver for application docking tests.
|
||||
testing = []
|
||||
|
||||
[dev-dependencies]
|
||||
egui = { workspace = true, features = ["default_fonts"] }
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Where each docking gesture starts, for control protocols and tests.
|
||||
|
||||
use std::{collections::HashMap, fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Id, Rect};
|
||||
|
||||
use super::{DockArea, Layout, Node, render::RESIZE_HANDLES};
|
||||
|
||||
/// A rendered docking control that starts a gesture. Groups, columns and windows are named by
|
||||
/// their first panel.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum Hit<P> {
|
||||
/// A tab: click activates, drag moves the panel (S1).
|
||||
Tab(P),
|
||||
/// A group's drag bar: click opens the group menu, drag moves the group (S2).
|
||||
DragBar(P),
|
||||
/// The top border of a group's strip: drag moves the group (S3).
|
||||
Border(P),
|
||||
/// The "…" button of a group whose tabs overflow; named by the active panel.
|
||||
Overflow(P),
|
||||
/// [D] A column bar: drag moves the column (S6).
|
||||
ColumnBar(P),
|
||||
/// [D] The »/« button of a column bar (M2).
|
||||
Collapse(P),
|
||||
/// An accordion entry's −/+ button.
|
||||
StackToggle(P),
|
||||
/// A floating window's title bar (S5).
|
||||
Title(P),
|
||||
/// A floating window's × button.
|
||||
Close(P),
|
||||
/// [D] A panel's icon in an iconic column (S7, M5).
|
||||
Icon(P),
|
||||
/// [D] The grip above an icon group (S8).
|
||||
IconGrip(P),
|
||||
/// A floating window's resize handle.
|
||||
Resize(P, ResizeEdge),
|
||||
Divider(Divider<P>),
|
||||
}
|
||||
|
||||
/// The edge or corner a floating window's resize handle moves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ResizeEdge {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// A divider that resizes what is on either side of it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum Divider<P> {
|
||||
/// A split, by its path from the root (see [`super::Action::ResizeSplit`]).
|
||||
Split(Vec<bool>),
|
||||
/// Below the accordion entry of this panel.
|
||||
Stack(P),
|
||||
/// [D] The inner edge of the column holding this panel first.
|
||||
Column(P),
|
||||
/// Below the dock or floating group holding this panel first.
|
||||
Group(P),
|
||||
}
|
||||
|
||||
fn key(area: Id) -> Id {
|
||||
area.with("hit-rects")
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'_, P> {
|
||||
/// Every gesture source the area drew in its last frame, with its rect in points. Hosts
|
||||
/// return these from a `ui.docking` control method beside the layout and `focused`; tests
|
||||
/// drag from their centres.
|
||||
pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit<P>, Rect)> {
|
||||
ctx.data(|d| d.get_temp::<Vec<(Hit<P>, Rect)>>(key(id))).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the controls the renderer registered this frame and store their rects.
|
||||
pub(super) fn record<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>, owned: &[Id]) {
|
||||
let mut names: HashMap<Id, Hit<P>> = HashMap::new();
|
||||
for panel in layout.panels() {
|
||||
let p = || panel.clone();
|
||||
let some = Some(panel);
|
||||
names.insert(area.with(("tab", panel)), Hit::Tab(p()));
|
||||
names.insert(area.with(("icon", panel)), Hit::Icon(p()));
|
||||
names.insert(area.with(("overflow", panel)), Hit::Overflow(p()));
|
||||
names.insert(area.with(("collapse", panel)), Hit::StackToggle(p()));
|
||||
names.insert(area.with(("stack-resize", panel)), Hit::Divider(Divider::Stack(p())));
|
||||
names.insert(area.with(("drag-bar", some)), Hit::DragBar(p()));
|
||||
names.insert(area.with(("drag-border", some)), Hit::Border(p()));
|
||||
names.insert(area.with(("icon-grip", some)), Hit::IconGrip(p()));
|
||||
names.insert(area.with(("column-bar", some)), Hit::ColumnBar(p()));
|
||||
names.insert(area.with(("column-collapse", some)), Hit::Collapse(p()));
|
||||
names.insert(area.with(("column-resize", some)), Hit::Divider(Divider::Column(p())));
|
||||
names.insert(area.with(("group-resize", some)), Hit::Divider(Divider::Group(p())));
|
||||
names.insert(area.with(("floating-divider", some)), Hit::Divider(Divider::Group(p())));
|
||||
let title = area.with(("move-group", some));
|
||||
names.insert(title, Hit::Title(p()));
|
||||
names.insert(title.with("close"), Hit::Close(p()));
|
||||
let window = area.with(("floating", panel));
|
||||
let edges = [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft];
|
||||
for (name, edge) in RESIZE_HANDLES.iter().zip(edges) {
|
||||
names.insert(window.with(*name), Hit::Resize(p(), edge));
|
||||
}
|
||||
}
|
||||
let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::<bool>::new())]).unwrap_or_default();
|
||||
while let Some((node, path)) = nodes.pop() {
|
||||
if let Node::Split { first, second, .. } = node {
|
||||
names.insert(area.with(("split", &path)), Hit::Divider(Divider::Split(path.clone())));
|
||||
let mut second_path = path.clone();
|
||||
second_path.push(true);
|
||||
let mut first_path = path;
|
||||
first_path.push(false);
|
||||
nodes.push((second, second_path));
|
||||
nodes.push((first, first_path));
|
||||
}
|
||||
}
|
||||
let hits: Vec<(Hit<P>, Rect)> =
|
||||
owned.iter().filter_map(|id| Some((names.remove(id)?, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect();
|
||||
ctx.data_mut(|d| d.insert_temp(key(area), hits));
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! [D] Iconic columns, flyouts and hit rects, driven through `testing::DockDriver`.
|
||||
|
||||
use super::testing::DockDriver;
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
use egui::{Key, Pos2, Rect, pos2, vec2};
|
||||
|
||||
struct Content;
|
||||
|
||||
impl DockContent<u32> for Content {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Body");
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
}
|
||||
|
||||
type Driver = DockDriver<u32, Content>;
|
||||
|
||||
/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0.
|
||||
fn docked(groups: &[&[u32]], iconic: bool) -> Layout<u32> {
|
||||
let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0);
|
||||
column.iconic = iconic;
|
||||
let dock = Node::Dock { side: Side::Start, columns: vec![column] };
|
||||
let canvas = Node::Tabs { panels: vec![0], active: 0 };
|
||||
Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }),
|
||||
floating: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_column(layout: &Layout<u32>) -> Column<u32> {
|
||||
match &layout.root {
|
||||
Some(Node::Split { first, .. }) => match first.as_ref() {
|
||||
Node::Dock { columns, .. } => columns.first().cloned().unwrap(),
|
||||
other => panic!("no dock: {other:?}"),
|
||||
},
|
||||
other => panic!("no split: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn groups(layout: &Layout<u32>) -> Vec<Vec<u32>> {
|
||||
first_column(layout).groups.iter().map(|g| g.panels.clone()).collect()
|
||||
}
|
||||
|
||||
fn hit(d: &Driver, hit: Hit<u32>) -> Rect {
|
||||
d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits()))
|
||||
}
|
||||
|
||||
fn open(d: &Driver) -> Option<u32> {
|
||||
flyout::<u32>(&d.ctx, d.id)
|
||||
}
|
||||
|
||||
fn texts(d: &Driver) -> Vec<String> {
|
||||
d.shapes()
|
||||
.iter()
|
||||
.filter_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const CANVAS: Pos2 = pos2(600.0, 400.0);
|
||||
|
||||
#[test]
|
||||
fn collapsed_column_shows_one_icon_per_panel_with_group_separators() {
|
||||
let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3)));
|
||||
let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3)));
|
||||
assert_eq!(one.width(), 36.0, "the column takes the style's iconic width");
|
||||
assert_eq!((grip1.bottom(), one.bottom()), (one.top(), two.top()), "a group's icons follow its grip and each other");
|
||||
assert_eq!(grip3.top() - two.bottom(), 7.0, "groups are apart by the separator");
|
||||
assert_eq!(three.top(), grip3.bottom());
|
||||
assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_an_iconic_column_shows_labels() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
|
||||
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
|
||||
let out = d.drag(edge.center(), pos2(150.0, edge.center().y));
|
||||
assert!(out.error.is_none());
|
||||
let width = first_column(&d.layout).iconic_width.unwrap();
|
||||
assert!((100.0..=160.0).contains(&width), "{width}");
|
||||
d.settle();
|
||||
let labels = texts(&d);
|
||||
assert!(["Panel 1", "Panel 2", "Panel 3"].iter().all(|l| labels.iter().any(|t| t == l)), "{labels:?}");
|
||||
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
|
||||
d.drag(edge.center(), pos2(20.0, edge.center().y));
|
||||
d.settle();
|
||||
assert_eq!(first_column(&d.layout).iconic_width, Some(36.0));
|
||||
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
let icon = hit(&d, Hit::Icon(2)).center();
|
||||
let out = d.click(icon);
|
||||
assert!(out.actions.contains(&Action::Activate { panel: 2 }));
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(2));
|
||||
let column = hit(&d, Hit::Icon(1));
|
||||
let tab = hit(&d, Hit::Tab(2));
|
||||
assert!(tab.left() >= column.right(), "the flyout opens on the canvas side: {tab:?}");
|
||||
assert!(tab.top() <= hit(&d, Hit::IconGrip(1)).bottom(), "beside its group");
|
||||
assert_eq!(first_column(&d.layout).groups.first().map(|g| g.active), Some(1));
|
||||
|
||||
// Another icon switches flyouts, the same icon closes.
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(3));
|
||||
assert!(d.hit(&Hit::Tab(2)).is_none());
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
|
||||
// Escape and a press outside close it.
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
d.key(Key::Escape);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
let inside = hit(&d, Hit::Tab(2)).center();
|
||||
d.click(inside);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(2), "a press inside keeps it");
|
||||
d.click(CANVAS);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
|
||||
// Auto-Collapse off: it stays until an icon closes it.
|
||||
d.auto_collapse = false;
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
d.key(Key::Escape);
|
||||
d.click(CANVAS);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(2));
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
|
||||
// U3: raising an iconic panel, as the Window menu does, opens its flyout.
|
||||
d.raise = Some(3);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_collapse_toggle_is_a_column_menu_event() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let layout = docked(&[&[1, 2]], true);
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, events: &mut Vec<DockEvent<u32>>| {
|
||||
let out = DockArea::new(egui::Id::new("menu")).show_content(ui, &layout, &DockStyle::from_ui(ui), &mut Content);
|
||||
events.extend(out.events);
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let bar = h.ctx.read_response(egui::Id::new("menu").with(("column-bar", Some(&1u32)))).unwrap().rect.center();
|
||||
h.input_mut().events.extend([
|
||||
egui::Event::PointerMoved(bar),
|
||||
egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(egui::Event::PointerButton {
|
||||
pos: bar,
|
||||
button: egui::PointerButton::Secondary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto-Collapse Iconic Panels").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state(), &vec![DockEvent::ToggleAutoCollapse]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_flyout_strip_floats_its_group() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
let bar = hit(&d, Hit::DragBar(1));
|
||||
let out = d.drag(bar.center(), CANVAS);
|
||||
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 1, to: Target::Float { .. } }]), "{:?}", out.actions);
|
||||
assert_eq!(groups(&d.layout), vec![vec![3]]);
|
||||
assert_eq!(d.layout.floating.len(), 1);
|
||||
assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::<Vec<_>>()), Some(vec![1, 2]));
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None, "the flyout closes with its group gone");
|
||||
|
||||
// A tab dragged out of a flyout floats that panel alone.
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
let tab = hit(&d, Hit::Tab(3));
|
||||
let out = d.drag(tab.center(), pos2(450.0, 200.0));
|
||||
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_and_icon_group_grip_drags_move_a_panel_and_a_group() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
let before = d.layout.clone();
|
||||
// Escape, focus loss and cancel_drag end an icon drag with no change.
|
||||
let icon = hit(&d, Hit::Icon(2)).center();
|
||||
d.hold(icon, CANVAS);
|
||||
d.key(Key::Escape);
|
||||
d.release(CANVAS);
|
||||
d.hold(icon, CANVAS);
|
||||
d.focus(false);
|
||||
d.focus(true);
|
||||
d.release(CANVAS);
|
||||
d.hold(icon, CANVAS);
|
||||
cancel_drag::<u32>(&d.ctx, d.id);
|
||||
d.release(CANVAS);
|
||||
assert_eq!(d.layout, before);
|
||||
|
||||
let out = d.drag(icon, CANVAS);
|
||||
let Some(Action::Float { panel: 2, rect }) = out.actions.first() else { panic!("{:?}", out.actions) };
|
||||
assert_eq!((rect[2], rect[3]), (240.0, 320.0), "a panel floated from its icon takes the column's expanded width");
|
||||
assert_eq!(groups(&d.layout), vec![vec![1], vec![3]]);
|
||||
|
||||
let grip = hit(&d, Hit::IconGrip(3)).center();
|
||||
let out = d.drag(grip, pos2(450.0, 250.0));
|
||||
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions);
|
||||
assert_eq!(groups(&d.layout), vec![vec![1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() {
|
||||
let mut layout = docked(&[&[1], &[2]], true);
|
||||
layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0]));
|
||||
let mut d = DockDriver::new(layout, Content);
|
||||
let bar = hit(&d, Hit::DragBar(5)).center();
|
||||
let one = hit(&d, Hit::Icon(1));
|
||||
let target = pos2(one.center().x, one.bottom() - 2.0);
|
||||
d.hold(bar, target);
|
||||
let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0));
|
||||
assert!(d.outlined(column.shrink(1.0)), "the column is outlined");
|
||||
let line = Rect::from_x_y_ranges(column.x_range(), (one.bottom() - 2.0)..=(one.bottom() + 2.0));
|
||||
assert!(d.filled(line), "with the insertion line under group 1");
|
||||
let out = d.release(target);
|
||||
assert!(
|
||||
out.actions.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true } } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert_eq!(groups(&d.layout), vec![vec![1], vec![5, 6], vec![2]]);
|
||||
assert!(first_column(&d.layout).iconic);
|
||||
}
|
||||
|
||||
/// Every kind of gesture source has a rect, and dragging from it does something.
|
||||
#[test]
|
||||
fn hit_rects_cover_every_gesture_source() {
|
||||
let mut expanded = Column::new(vec![Group::new(vec![1, 2, 3, 4, 5, 6, 7], 0), Group::new(vec![8], 0)], 150.0);
|
||||
expanded.width = 150.0;
|
||||
let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0);
|
||||
icons.iconic = true;
|
||||
let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] };
|
||||
let stack = Node::Stack {
|
||||
entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }],
|
||||
};
|
||||
let frames = Node::Split {
|
||||
axis: SplitAxis::Vertical,
|
||||
size: SplitSize::Ratio(0.5),
|
||||
first: Box::new(Node::Tabs { panels: vec![0], active: 0 }),
|
||||
second: Box::new(stack),
|
||||
};
|
||||
let mut window = Floating::single(vec![12], 0, [500.0, 60.0, 220.0, 300.0]);
|
||||
window.groups.push(Group::new(vec![13], 0));
|
||||
let layout = Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }),
|
||||
floating: vec![window],
|
||||
};
|
||||
let d = DockDriver::new(layout.clone(), Content);
|
||||
let hits = d.hits();
|
||||
let kinds = |f: &dyn Fn(&Hit<u32>) -> bool| hits.iter().filter(|(h, _)| f(h)).count();
|
||||
let expected: [(&str, &dyn Fn(&Hit<u32>) -> bool); 15] = [
|
||||
("tab", &|h| matches!(h, Hit::Tab(_))),
|
||||
("drag bar", &|h| matches!(h, Hit::DragBar(_))),
|
||||
("border", &|h| matches!(h, Hit::Border(_))),
|
||||
("overflow", &|h| matches!(h, Hit::Overflow(_))),
|
||||
("column bar", &|h| matches!(h, Hit::ColumnBar(_))),
|
||||
("collapse", &|h| matches!(h, Hit::Collapse(_))),
|
||||
("stack toggle", &|h| matches!(h, Hit::StackToggle(_))),
|
||||
("title", &|h| matches!(h, Hit::Title(12))),
|
||||
("close", &|h| matches!(h, Hit::Close(12))),
|
||||
("icon", &|h| matches!(h, Hit::Icon(9))),
|
||||
("icon grip", &|h| matches!(h, Hit::IconGrip(9))),
|
||||
("resize", &|h| matches!(h, Hit::Resize(12, _))),
|
||||
("split", &|h| matches!(h, Hit::Divider(Divider::Split(_)))),
|
||||
("column divider", &|h| matches!(h, Hit::Divider(Divider::Column(_)))),
|
||||
("group divider", &|h| matches!(h, Hit::Divider(Divider::Group(_)))),
|
||||
];
|
||||
for (name, kind) in expected {
|
||||
assert!(kinds(kind) > 0, "{name}: {hits:?}");
|
||||
}
|
||||
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10)))));
|
||||
assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 5);
|
||||
for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
|
||||
let mut d = DockDriver::new(layout.clone(), Content);
|
||||
let from = rect.center();
|
||||
let moved = d.hold(from, from + vec2(-30.0, 40.0));
|
||||
let dragging = is_dragging::<u32>(&d.ctx, d.id);
|
||||
let released = d.release(from + vec2(-30.0, 40.0));
|
||||
assert!(dragging || !moved.actions.is_empty() || !released.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
|
||||
}
|
||||
for (hit, rect) in hits.iter().filter(|(h, _)| matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
|
||||
let mut d = DockDriver::new(layout.clone(), Content);
|
||||
let out = d.click(rect.center());
|
||||
let opened = egui::Popup::is_any_open(&d.ctx);
|
||||
assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,18 @@
|
||||
|
||||
pub mod chrome;
|
||||
mod dock_model;
|
||||
mod hits;
|
||||
mod model;
|
||||
mod render;
|
||||
mod resize;
|
||||
mod surface;
|
||||
mod targets;
|
||||
#[cfg(any(test, feature = "testing"))]
|
||||
pub mod testing;
|
||||
mod workspace;
|
||||
|
||||
pub use hits::{Divider, Hit, ResizeEdge};
|
||||
pub use render::{close_flyout, flyout, open_flyout};
|
||||
pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target};
|
||||
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
|
||||
pub use surface::{
|
||||
@@ -32,6 +37,8 @@ mod floating_tests;
|
||||
#[cfg(test)]
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
mod iconic_tests;
|
||||
#[cfg(test)]
|
||||
mod model_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -10,15 +10,11 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p
|
||||
|
||||
use super::{GroupKind, Renderer};
|
||||
use crate::docking::{
|
||||
Action, Column, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target,
|
||||
Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target,
|
||||
targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line},
|
||||
};
|
||||
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
|
||||
|
||||
/// Width of an iconic column without a saved width.
|
||||
const ICONIC_WIDTH: f32 = 36.0;
|
||||
/// Widest an iconic column gets by dragging its inner edge.
|
||||
const ICONIC_MAX: f32 = 240.0;
|
||||
/// Width of the »/« button at the outer end of a column bar.
|
||||
const COLLAPSE_WIDTH: f32 = 18.0;
|
||||
|
||||
@@ -44,8 +40,12 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side {
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::docking) fn column_width<P>(column: &Column<P>) -> f32 {
|
||||
if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH) } else { column.width }
|
||||
pub(in crate::docking) fn column_width<P>(column: &Column<P>, style: &DockStyle) -> f32 {
|
||||
if column.iconic {
|
||||
column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width)
|
||||
} else {
|
||||
column.width
|
||||
}
|
||||
}
|
||||
|
||||
/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved
|
||||
@@ -104,7 +104,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
};
|
||||
let gap = self.style.gap;
|
||||
let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width());
|
||||
let width = columns.iter().map(column_width).sum::<f32>().clamp(0.0, room);
|
||||
let width = columns.iter().map(|c| column_width(c, self.style)).sum::<f32>().clamp(0.0, room);
|
||||
let (dock, rest) = match edge_of(side, self.style.right_to_left) {
|
||||
Edge::Left => (
|
||||
Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())),
|
||||
@@ -141,12 +141,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
if usable(rect) {
|
||||
ui.painter().rect_filled(rect, 0.0, self.style.background);
|
||||
}
|
||||
let total: f32 = columns.iter().map(column_width).sum();
|
||||
let total: f32 = columns.iter().map(|c| column_width(c, self.style)).sum();
|
||||
let mut x = outer;
|
||||
let mut drawn = Vec::new();
|
||||
for (index, column) in columns.iter().enumerate() {
|
||||
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue };
|
||||
let width = column_width(column).min((rect.width() - (x - outer).abs()).max(0.0));
|
||||
let width = column_width(column, self.style).min((rect.width() - (x - outer).abs()).max(0.0));
|
||||
let inner = x + inward * width;
|
||||
let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range());
|
||||
self.column(ui, column, first, column_rect, edge);
|
||||
@@ -161,8 +161,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
// Registered after the columns, so the narrow hit areas win at the edges.
|
||||
for (column, first, width, inner) in drawn {
|
||||
let others = total - column_width(column);
|
||||
let (lo, hi) = if column.iconic { (ICONIC_WIDTH, ICONIC_MAX) } else { self.column_bounds(column) };
|
||||
let others = total - column_width(column, self.style);
|
||||
let (lo, hi) = if column.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(column) };
|
||||
let hi = hi.min(room - others).max(lo.min(room - others).max(0.0));
|
||||
let lo = lo.min(hi);
|
||||
let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range());
|
||||
@@ -216,6 +216,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom())));
|
||||
self.column_bar(ui, column, first, bar, rect, edge);
|
||||
let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max);
|
||||
if column.iconic && usable(body) {
|
||||
self.iconic_column(ui, column, body, edge);
|
||||
}
|
||||
if column.iconic || !usable(body) {
|
||||
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
|
||||
return;
|
||||
@@ -404,6 +407,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
if self.menu_action(ui, &label) {
|
||||
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
|
||||
}
|
||||
if self.auto_collapse_item(ui) {
|
||||
self.output.events.push(DockEvent::ToggleAutoCollapse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,6 +417,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
/// covers `pointer`. Covered dock targets take nothing.
|
||||
fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool {
|
||||
self.blockers.iter().any(|r| r.contains(pointer))
|
||||
|| self.iconic.covers(pointer)
|
||||
|| self.layout.floating.iter().any(|window| {
|
||||
let Some(first) = window.panels().next() else { return false };
|
||||
!moving.is_some_and(|p| window.contains(p))
|
||||
@@ -486,7 +493,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
/// The insertion line of a dock target.
|
||||
pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) {
|
||||
if usable(line) {
|
||||
self.preview_painter(ctx).rect_filled(line, 1.0, self.style.accent);
|
||||
let painter = self.preview_painter(ctx);
|
||||
self.iconic.outline(&painter, line, self.style);
|
||||
painter.rect_filled(line, 1.0, self.style.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! [D] Iconic columns: one icon per panel, groups apart, and the flyout an icon opens.
|
||||
//!
|
||||
//! Behaviour follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side,
|
||||
//! names when the column is widened) and PhotoCraft `dock.rs` (Auto-Collapse Iconic Panels).
|
||||
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
|
||||
|
||||
use super::{GroupKind, Renderer, dock::Edge};
|
||||
use crate::docking::{
|
||||
Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag,
|
||||
chrome::{ChromeStyle, FloatingFrame},
|
||||
is_dragging,
|
||||
targets::{DockDrop, DockTarget, horizontal_line, usable},
|
||||
};
|
||||
|
||||
/// Height of the grip above each icon group.
|
||||
const GRIP: f32 = 6.0;
|
||||
/// Room between two icon groups, with the separator line in its middle.
|
||||
const SEPARATOR: f32 = 7.0;
|
||||
/// Height of a flyout, or of a panel floated from an icon, when nothing else sizes it.
|
||||
const FLYOUT_HEIGHT: f32 = 320.0;
|
||||
|
||||
/// The panel whose icon opened the flyout of an area.
|
||||
#[derive(Clone)]
|
||||
struct Open<P>(P);
|
||||
|
||||
fn key(area: Id) -> Id {
|
||||
area.with("flyout")
|
||||
}
|
||||
|
||||
/// [D] Open the flyout of the group holding `panel`, when that group is in an iconic column.
|
||||
/// The flyout shows from the next frame on. [`crate::docking::DockArea::raise_panel`] does the
|
||||
/// same for a panel the Window menu opens.
|
||||
pub fn open_flyout<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id, panel: P) {
|
||||
ctx.data_mut(|d| d.insert_temp(key(area_id), Open(panel)));
|
||||
}
|
||||
|
||||
/// [D] Close the area's flyout.
|
||||
pub fn close_flyout<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) {
|
||||
ctx.data_mut(|d| d.remove::<Open<P>>(key(area_id)));
|
||||
}
|
||||
|
||||
/// [D] The panel whose icon opened the area's flyout, while one is open.
|
||||
pub fn flyout<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) -> Option<P> {
|
||||
ctx.data(|d| d.get_temp::<Open<P>>(key(area_id))).map(|open| open.0)
|
||||
}
|
||||
|
||||
/// The flyout an iconic column asks for this frame.
|
||||
struct Request<P> {
|
||||
group: Group<P>,
|
||||
/// The icons of the group, where the flyout's top aligns.
|
||||
icons: Rect,
|
||||
/// The iconic column, which a press does not close the flyout in.
|
||||
column: Rect,
|
||||
edge: Edge,
|
||||
/// The column's expanded width.
|
||||
width: f32,
|
||||
}
|
||||
|
||||
/// Per-frame iconic state of a renderer.
|
||||
pub(in crate::docking) struct Iconic<P> {
|
||||
auto_collapse: bool,
|
||||
/// Iconic column bodies drawn this frame, outlined by a drop into them (P9).
|
||||
columns: Vec<Rect>,
|
||||
/// Expanded column width of each panel drawn as an icon: a panel floated from its icon
|
||||
/// takes it.
|
||||
widths: Vec<(P, f32)>,
|
||||
request: Option<Request<P>>,
|
||||
/// The flyout drawn this frame; it occludes dock targets behind it.
|
||||
rect: Option<Rect>,
|
||||
}
|
||||
|
||||
impl<P: PartialEq> Iconic<P> {
|
||||
pub(in crate::docking) fn new(auto_collapse: bool) -> Self {
|
||||
Self { auto_collapse, columns: Vec::new(), widths: Vec::new(), request: None, rect: None }
|
||||
}
|
||||
|
||||
pub(super) fn covers(&self, pointer: Pos2) -> bool {
|
||||
self.rect.is_some_and(|rect| rect.contains(pointer))
|
||||
}
|
||||
|
||||
/// Outline the iconic column an insertion line lies in.
|
||||
pub(super) fn outline(&self, painter: &egui::Painter, line: Rect, style: &DockStyle) {
|
||||
if let Some(column) = self.columns.iter().find(|column| column.shrink(1.0).contains(line.center())) {
|
||||
painter.rect_stroke(column.shrink(1.0), 0.0, Stroke::new(2.0, style.accent), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn float_width(&self, panel: &P) -> Option<f32> {
|
||||
self.widths.iter().find(|(p, _)| p == panel).map(|(_, width)| *width)
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
fn row_height(&self) -> f32 {
|
||||
self.style.icon_size + 10.0
|
||||
}
|
||||
|
||||
fn icon_group_height(&self, group: &Group<P>) -> f32 {
|
||||
GRIP + group.panels.len() as f32 * self.row_height()
|
||||
}
|
||||
|
||||
/// The body of an iconic column: icon groups top to bottom, scrolling when they do not fit.
|
||||
pub(super) fn iconic_column(&mut self, ui: &mut Ui, column: &Column<P>, body: Rect, edge: Edge) {
|
||||
self.iconic.columns.push(body);
|
||||
for panel in column.groups.iter().flat_map(|g| g.panels.iter()) {
|
||||
self.iconic.widths.push((panel.clone(), column.width));
|
||||
}
|
||||
// Opening a panel from the Window menu opens its flyout (U3).
|
||||
if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) {
|
||||
open_flyout(ui.ctx(), self.id, panel.clone());
|
||||
}
|
||||
let total: f32 = column.groups.iter().map(|g| self.icon_group_height(g)).sum::<f32>()
|
||||
+ column.groups.len().saturating_sub(1) as f32 * SEPARATOR;
|
||||
if total > body.height() + 0.5 {
|
||||
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return };
|
||||
let mut child =
|
||||
ui.new_child(UiBuilder::new().id_salt(("icon-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
child.set_clip_rect(body.intersect(ui.clip_rect()));
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(self.id.with(("icon-scroll", first)))
|
||||
.auto_shrink([false, false])
|
||||
.scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL)
|
||||
.show(&mut child, |ui| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover());
|
||||
self.icon_groups(ui, column, area, body, edge);
|
||||
});
|
||||
} else {
|
||||
self.icon_groups(ui, column, body, body, edge);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon groups in `area` (the scrolled content when the column scrolls), their drop bands
|
||||
/// clipped to the visible `view`.
|
||||
fn icon_groups(&mut self, ui: &mut Ui, column: &Column<P>, area: Rect, view: Rect, edge: Edge) {
|
||||
let open = flyout::<P>(ui.ctx(), self.id);
|
||||
let labels = view.width() >= self.style.iconic_label_width;
|
||||
let xs = view.x_range();
|
||||
let mut top = area.top();
|
||||
let mut bands = Vec::new();
|
||||
for (index, group) in column.groups.iter().enumerate() {
|
||||
let Some(first) = group.panels.first() else { continue };
|
||||
if index > 0 {
|
||||
let y = top + SEPARATOR * 0.5;
|
||||
ui.painter().with_clip_rect(view).hline(area.x_range().shrink(4.0), y, self.style.border);
|
||||
top += SEPARATOR;
|
||||
}
|
||||
let rect = Rect::from_min_size(pos2(area.left(), top), vec2(area.width(), self.icon_group_height(group)));
|
||||
let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP));
|
||||
self.icon_grip(ui, group, grip, column.width);
|
||||
let showing = open.as_ref().filter(|p| group.panels.contains(p));
|
||||
let mut y = grip.bottom();
|
||||
for panel in &group.panels {
|
||||
let cell = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), self.row_height()));
|
||||
self.icon(ui, panel, cell, labels, showing);
|
||||
y = cell.bottom();
|
||||
}
|
||||
// The state again: a click above may have opened or closed this group's flyout.
|
||||
if flyout::<P>(ui.ctx(), self.id).is_some_and(|p| group.panels.contains(&p)) {
|
||||
self.iconic.request = Some(Request { group: group.clone(), icons: rect.intersect(view), column: view, edge, width: column.width });
|
||||
}
|
||||
bands.push((first.clone(), rect));
|
||||
top = rect.bottom();
|
||||
}
|
||||
// P9: the upper half of an icon group inserts above it, the lower half and the room
|
||||
// under the last group below it. The line sits on the group's edge.
|
||||
let count = bands.len();
|
||||
for (index, (anchor, rect)) in bands.into_iter().enumerate() {
|
||||
let middle = rect.center().y;
|
||||
let upper_top = if index == 0 { view.top() } else { rect.top() - SEPARATOR * 0.5 };
|
||||
let lower_bottom = if index + 1 == count { view.bottom() } else { rect.bottom() + SEPARATOR * 0.5 };
|
||||
for (below, ys, line) in [(false, upper_top..=middle, rect.top()), (true, middle..=lower_bottom, rect.bottom())] {
|
||||
let band = Rect::from_x_y_ranges(xs, ys).intersect(view);
|
||||
if usable(band) {
|
||||
let line = horizontal_line(line.clamp(view.top() + 2.0, view.bottom() - 2.0), xs);
|
||||
self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The grip above an icon group drags the whole group (S8).
|
||||
fn icon_grip(&mut self, ui: &mut Ui, group: &Group<P>, grip: Rect, width: f32) {
|
||||
let Some(first) = group.panels.first() else { return };
|
||||
let movable = group.panels.iter().all(|p| self.permissions(p).movable);
|
||||
let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag());
|
||||
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label));
|
||||
let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text };
|
||||
let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect()));
|
||||
painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y - 1.0, Stroke::new(1.0, color));
|
||||
painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color));
|
||||
if movable && response.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
|
||||
let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT);
|
||||
let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height)));
|
||||
let drag = GroupDrag {
|
||||
panel: first.clone(),
|
||||
original: [source.left(), source.top(), source.width(), source.height()],
|
||||
gesture: GroupGesture::Group,
|
||||
members: group.panels.clone(),
|
||||
origin,
|
||||
source,
|
||||
};
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
|
||||
close_flyout::<P>(ui.ctx(), self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// One panel's icon: a click opens, switches or closes the flyout (M5); a drag moves the
|
||||
/// panel (S7). `showing` is the panel whose icon opened this group's flyout.
|
||||
fn icon(&mut self, ui: &mut Ui, panel: &P, cell: Rect, labels: bool, showing: Option<&P>) {
|
||||
let movable = self.permissions(panel).movable;
|
||||
let label = self.label_of(panel);
|
||||
let response = ui.interact(cell, self.id.with(("icon", panel)), if movable { Sense::click_and_drag() } else { Sense::click() });
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
let current = showing == Some(panel);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), current, &label));
|
||||
let painter = ui.painter().with_clip_rect(cell.intersect(ui.clip_rect()));
|
||||
if current || response.hovered() {
|
||||
painter.rect_filled(cell.shrink(2.0), 3.0, self.style.active_background);
|
||||
}
|
||||
if response.has_focus() {
|
||||
painter.rect_stroke(cell.shrink(1.0), 3.0, Stroke::new(1.0, self.style.accent), StrokeKind::Inside);
|
||||
}
|
||||
let size = self.style.icon_size;
|
||||
let inset = ((self.style.iconic_width - size) * 0.5).max(2.0);
|
||||
let icon = Rect::from_min_size(pos2(cell.left() + inset, cell.center().y - size * 0.5), vec2(size, size));
|
||||
let color = if current { self.style.text } else { self.style.inactive_text };
|
||||
if let Some(source) = self.content.icon(panel) {
|
||||
egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell));
|
||||
} else {
|
||||
let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect();
|
||||
painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||||
painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color);
|
||||
}
|
||||
if labels {
|
||||
let text = self.content.label(ui, panel, &label, &self.style.font, color);
|
||||
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone());
|
||||
painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color);
|
||||
} else if response.hovered() && !response.dragged() {
|
||||
response.clone().on_hover_text(&label);
|
||||
}
|
||||
if response.clicked() {
|
||||
if current {
|
||||
close_flyout::<P>(ui.ctx(), self.id);
|
||||
} else {
|
||||
if showing.is_none() {
|
||||
ui.ctx().move_to_top(FloatingFrame::layer(self.id.with("flyout")));
|
||||
}
|
||||
open_flyout(ui.ctx(), self.id, panel.clone());
|
||||
self.output.actions.push(crate::docking::Action::Activate { panel: panel.clone() });
|
||||
self.output.focused = Some(panel.clone());
|
||||
}
|
||||
}
|
||||
if movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), cell) {
|
||||
close_flyout::<P>(ui.ctx(), self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Auto-Collapse Iconic Panels item of a column menu (U1): true when chosen.
|
||||
pub(super) fn auto_collapse_item(&mut self, ui: &mut Ui) -> bool {
|
||||
let logical = self.style.auto_collapse_label.clone();
|
||||
let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text);
|
||||
let mut on = self.iconic.auto_collapse;
|
||||
let response = ui.add(egui::Checkbox::new(&mut on, text));
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), self.iconic.auto_collapse, &logical));
|
||||
if response.clicked() {
|
||||
ui.close();
|
||||
}
|
||||
response.clicked()
|
||||
}
|
||||
|
||||
/// Draw the flyout an iconic column asked for, beside the column on the canvas side, or
|
||||
/// forget a flyout whose group is no longer iconic. With Auto-Collapse, Escape or a press
|
||||
/// outside the flyout and its column closes it.
|
||||
pub(in crate::docking) fn flyout(&mut self, ctx: &Context) {
|
||||
let Some(request) = self.iconic.request.take() else {
|
||||
close_flyout::<P>(ctx, self.id);
|
||||
return;
|
||||
};
|
||||
let Some(active) = request.group.active_panel().cloned() else { return };
|
||||
let id = self.id.with("flyout");
|
||||
if self.iconic.auto_collapse && self.flyout_dismissed(ctx, id, request.column) {
|
||||
close_flyout::<P>(ctx, self.id);
|
||||
return;
|
||||
}
|
||||
let screen = ctx.content_rect();
|
||||
let bounds = self.workspace.intersect(screen);
|
||||
let width = request.width.max(self.style.min_pane).min(bounds.width());
|
||||
let height = self
|
||||
.content
|
||||
.floating_size(&active)
|
||||
.map(|size| size.y)
|
||||
.or(request.group.height)
|
||||
.filter(|h| h.is_finite() && *h > 0.0)
|
||||
.unwrap_or(FLYOUT_HEIGHT)
|
||||
.max(self.style.tab_height + self.style.min_pane)
|
||||
.min(bounds.height());
|
||||
let left = match request.edge {
|
||||
Edge::Left => request.column.right(),
|
||||
Edge::Right => request.column.left() - width,
|
||||
};
|
||||
let top = request.icons.top().min(bounds.bottom() - height).max(bounds.top());
|
||||
let rect = Rect::from_min_size(pos2(left, top), vec2(width, height));
|
||||
let style = ChromeStyle::from_dock(self.style);
|
||||
let group = request.group;
|
||||
let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| {
|
||||
self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock);
|
||||
});
|
||||
self.iconic.rect = Some(response.rect);
|
||||
}
|
||||
|
||||
/// Escape (outside a drag) or a primary press outside the flyout, its column and any menu.
|
||||
fn flyout_dismissed(&self, ctx: &Context, id: Id, column: Rect) -> bool {
|
||||
let escape = !is_dragging::<P>(ctx, self.id) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape));
|
||||
let press = ctx.input(|i| i.pointer.primary_pressed().then(|| i.pointer.press_origin()).flatten());
|
||||
let outside = press.is_some_and(|pos| {
|
||||
let inside = ctx.memory(|m| m.area_rect(id)).is_some_and(|r| r.contains(pos)) || column.contains(pos);
|
||||
let menu = ctx.layer_id_at(pos).is_some_and(|layer| matches!(layer.order, Order::Foreground | Order::Tooltip));
|
||||
!inside && !menu
|
||||
});
|
||||
escape || outside
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node
|
||||
mod dock;
|
||||
mod floating;
|
||||
mod frame;
|
||||
mod iconic;
|
||||
#[cfg(feature = "native-windows")]
|
||||
mod native;
|
||||
|
||||
@@ -16,6 +17,9 @@ pub(in crate::docking) use frame::{FrameState, view};
|
||||
|
||||
pub(in crate::docking) use group::GroupKind;
|
||||
|
||||
pub(in crate::docking) use iconic::Iconic;
|
||||
pub use iconic::{close_flyout, flyout, open_flyout};
|
||||
|
||||
pub(in crate::docking) use floating::RESIZE_HANDLES;
|
||||
mod group;
|
||||
mod preview;
|
||||
@@ -35,6 +39,8 @@ pub(super) struct Renderer<'a, P> {
|
||||
pub(super) owned_interactions: Vec<Id>,
|
||||
pub(super) output: DockOutput<P>,
|
||||
pub(super) frame: FrameState<P>,
|
||||
/// [D] Flyout and iconic column state for this frame.
|
||||
pub(super) iconic: Iconic<P>,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
@@ -123,6 +129,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None);
|
||||
let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max);
|
||||
let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click());
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, self.label_of(&entry.panel))
|
||||
});
|
||||
@@ -222,7 +229,7 @@ pub(in crate::docking) fn stripless<P>(panels: &[P], content: &dyn DockContent<P
|
||||
pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, content: &dyn DockContent<P>) -> PanelLimits {
|
||||
match node {
|
||||
Node::Dock { columns, .. } => {
|
||||
let width: f32 = columns.iter().map(dock::column_width).sum();
|
||||
let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum();
|
||||
PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() }
|
||||
}
|
||||
Node::Tabs { panels, .. } => {
|
||||
|
||||
@@ -269,7 +269,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) {
|
||||
return size;
|
||||
}
|
||||
let mut size = source;
|
||||
let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT));
|
||||
if matches!(self.layout.place(panel), Some(Place::Dock { .. })) {
|
||||
size.y = size.y.min(DOCKED_FLOAT_HEIGHT);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,16 @@ pub struct DockStyle {
|
||||
pub seam_band: f32,
|
||||
/// [D] Width of the band along the docked area's left and right edges that makes a new dock.
|
||||
pub new_dock_band: f32,
|
||||
/// [D] Side of a panel icon in an iconic column; each icon row is 10 pt taller.
|
||||
pub icon_size: f32,
|
||||
/// [D] Width of an iconic column without a saved width, and the narrowest it gets.
|
||||
pub iconic_width: f32,
|
||||
/// [D] Widest an iconic column gets by dragging its inner edge.
|
||||
pub iconic_max_width: f32,
|
||||
/// [D] An iconic column at least this wide shows panel names beside the icons.
|
||||
pub iconic_label_width: f32,
|
||||
/// [D] Column menu item that toggles Auto-Collapse Iconic Panels.
|
||||
pub auto_collapse_label: String,
|
||||
/// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide
|
||||
/// layout direction, so the host sets this from its locale. Frame splits keep their sides.
|
||||
pub right_to_left: bool,
|
||||
@@ -123,6 +133,11 @@ impl DockStyle {
|
||||
column_bar_height: 14.0,
|
||||
seam_band: 8.0,
|
||||
new_dock_band: 12.0,
|
||||
icon_size: 20.0,
|
||||
iconic_width: 36.0,
|
||||
iconic_max_width: 240.0,
|
||||
iconic_label_width: 96.0,
|
||||
auto_collapse_label: "Auto-Collapse Iconic Panels".into(),
|
||||
right_to_left: false,
|
||||
move_column_label: "Move panel column".into(),
|
||||
collapse_to_icons_label: "Collapse to Icons".into(),
|
||||
@@ -153,9 +168,14 @@ impl DockStyle {
|
||||
self.column_bar_height,
|
||||
self.seam_band,
|
||||
self.new_dock_band,
|
||||
self.icon_size,
|
||||
self.iconic_width,
|
||||
self.iconic_max_width,
|
||||
self.iconic_label_width,
|
||||
]
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && (0.0..=4096.0).contains(v))
|
||||
&& self.iconic_width <= self.iconic_max_width
|
||||
&& self.tab_height > 0.0
|
||||
&& self.font.size > 0.0
|
||||
}
|
||||
@@ -187,6 +207,9 @@ pub enum DockEvent<P> {
|
||||
/// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one
|
||||
/// passed to [`DockArea::maximized`].
|
||||
ToggleMaximize { panel: P },
|
||||
/// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its
|
||||
/// preference and passes it to [`DockArea::auto_collapse_iconic`].
|
||||
ToggleAutoCollapse,
|
||||
}
|
||||
|
||||
/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay.
|
||||
@@ -448,11 +471,19 @@ pub struct DockArea<'a, P = ()> {
|
||||
blockers: Vec<Rect>,
|
||||
maximized: Option<P>,
|
||||
hide: HidePanels<'a, P>,
|
||||
auto_collapse: bool,
|
||||
}
|
||||
|
||||
impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
|
||||
pub fn new(id: Id) -> Self {
|
||||
Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None }
|
||||
Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true }
|
||||
}
|
||||
|
||||
/// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press
|
||||
/// outside it. Off, it stays open until its icon or another icon is clicked.
|
||||
pub fn auto_collapse_iconic(mut self, on: bool) -> Self {
|
||||
self.auto_collapse = on;
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximize Frame: the docked frame holding `panel` fills the area; other frames and
|
||||
@@ -620,14 +651,17 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
|
||||
owned_interactions: Vec::new(),
|
||||
output: DockOutput::default(),
|
||||
frame,
|
||||
iconic: super::render::Iconic::new(self.auto_collapse),
|
||||
};
|
||||
if let Some(root) = &view.root {
|
||||
renderer.node(ui, root, rect, &mut Vec::new());
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
}
|
||||
renderer.flyout(ui.ctx());
|
||||
renderer.floating(ui.ctx(), &view.floating);
|
||||
renderer.finish_group_drag(ui.ctx());
|
||||
renderer.finish_drag(ui.ctx());
|
||||
super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
|
||||
renderer.output
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Drive a [`DockArea`] with real egui input in application tests (feature `testing`).
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut driver = DockDriver::new(app_layout(), Panels::default());
|
||||
//! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap();
|
||||
//! driver.drag(bar.center(), egui::pos2(4.0, 300.0));
|
||||
//! assert!(driver.layout.dock_path(Side::Start).is_some());
|
||||
//! ```
|
||||
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, epaint::ClippedShape, vec2};
|
||||
|
||||
use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout};
|
||||
|
||||
/// Pointer travel that starts a drag before moving to its destination.
|
||||
const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0);
|
||||
|
||||
type StyleFn = Box<dyn Fn(&mut DockStyle)>;
|
||||
|
||||
/// A [`DockArea`] in a headless egui context, with its layout and content. Each frame advances
|
||||
/// the clock by 1/60 s, so double-clicks register. [`Self::step`] applies what a frame emits,
|
||||
/// the way an application's command path would.
|
||||
pub struct DockDriver<P, C> {
|
||||
pub ctx: Context,
|
||||
pub layout: Layout<P>,
|
||||
pub content: C,
|
||||
/// The area's id, for [`DockArea::hit_rects`] and the free docking functions.
|
||||
pub id: Id,
|
||||
/// The window, 800x600 by default.
|
||||
pub screen: Rect,
|
||||
/// Where the area draws inside the window; the whole window by default.
|
||||
pub area: Rect,
|
||||
/// Whether the window has focus. Losing it cancels gestures (C2).
|
||||
pub focused: bool,
|
||||
/// Modifiers held for the following events.
|
||||
pub modifiers: Modifiers,
|
||||
/// Passed to [`DockArea::auto_collapse_iconic`].
|
||||
pub auto_collapse: bool,
|
||||
/// Passed to [`DockArea::maximized`].
|
||||
pub maximized: Option<P>,
|
||||
/// Passed to [`DockArea::raise_panel`] on the next frame only.
|
||||
pub raise: Option<P>,
|
||||
style: Option<StyleFn>,
|
||||
shapes: Vec<ClippedShape>,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> DockDriver<P, C> {
|
||||
/// A driver whose first frames have settled, so every control has its rect.
|
||||
pub fn new(layout: Layout<P>, content: C) -> Self {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let mut driver = Self {
|
||||
ctx: Context::default(),
|
||||
layout,
|
||||
content,
|
||||
id: Id::new("dock-driver"),
|
||||
screen,
|
||||
area: screen,
|
||||
focused: true,
|
||||
modifiers: Modifiers::NONE,
|
||||
auto_collapse: true,
|
||||
maximized: None,
|
||||
raise: None,
|
||||
style: None,
|
||||
shapes: Vec::new(),
|
||||
time: 0.0,
|
||||
};
|
||||
driver.settle();
|
||||
driver
|
||||
}
|
||||
|
||||
/// Adjust the style each frame, after [`DockStyle::from_ui`]. Settles again.
|
||||
pub fn with_style(mut self, style: impl Fn(&mut DockStyle) + 'static) -> Self {
|
||||
self.style = Some(Box::new(style));
|
||||
self.settle();
|
||||
self
|
||||
}
|
||||
|
||||
/// Draw in `area` of the window. Settles again.
|
||||
pub fn with_area(mut self, area: Rect) -> Self {
|
||||
self.area = area;
|
||||
self.settle();
|
||||
self
|
||||
}
|
||||
|
||||
/// Render one frame with `events` and return its output. Nothing is applied.
|
||||
pub fn frame(&mut self, events: Vec<Event>) -> DockOutput<P> {
|
||||
let mut output = DockOutput::default();
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(self.screen),
|
||||
events: std::iter::once(Event::ModifiersChanged(self.modifiers)).chain(events).collect(),
|
||||
focused: self.focused,
|
||||
time: Some(self.time),
|
||||
..Default::default()
|
||||
};
|
||||
let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id);
|
||||
let (style_fn, maximized, raise, auto) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse);
|
||||
let mut full = self.ctx.run_ui(input, |ui| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
if let Some(adjust) = style_fn {
|
||||
adjust(&mut style);
|
||||
}
|
||||
ui.scope_builder(UiBuilder::new().max_rect(area), |ui| {
|
||||
let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto);
|
||||
if let Some(panel) = raise.clone() {
|
||||
dock = dock.raise_panel(panel);
|
||||
}
|
||||
output = dock.show_content(ui, layout, &style, content);
|
||||
});
|
||||
});
|
||||
full.textures_delta.clear();
|
||||
self.shapes = full.shapes;
|
||||
output
|
||||
}
|
||||
|
||||
/// Render one frame and apply its actions, all or none, with the content's permissions. A
|
||||
/// rejected batch is reported in the output's `error`.
|
||||
pub fn step(&mut self, events: Vec<Event>) -> DockOutput<P> {
|
||||
let mut output = self.frame(events);
|
||||
if output.error.is_none()
|
||||
&& let Err(error) = self.layout.apply_all(output.actions.clone(), |p| self.content.permissions(p))
|
||||
{
|
||||
output.error = Some(error);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Empty frames until moved widgets report their rects; egui reports them a frame late.
|
||||
pub fn settle(&mut self) {
|
||||
for _ in 0..3 {
|
||||
self.step(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, pos: Pos2) -> DockOutput<P> {
|
||||
self.step(vec![Event::PointerMoved(pos)])
|
||||
}
|
||||
|
||||
pub fn press(&mut self, pos: Pos2) -> DockOutput<P> {
|
||||
self.step(vec![Event::PointerMoved(pos), self.button(pos, true)])
|
||||
}
|
||||
|
||||
pub fn release(&mut self, pos: Pos2) -> DockOutput<P> {
|
||||
self.step(vec![Event::PointerMoved(pos), self.button(pos, false)])
|
||||
}
|
||||
|
||||
/// Press and release at `pos`; the output is the release frame's.
|
||||
pub fn click(&mut self, pos: Pos2) -> DockOutput<P> {
|
||||
self.press(pos);
|
||||
self.release(pos)
|
||||
}
|
||||
|
||||
/// Press at `from`, move past the drag threshold and on to `to`, without releasing.
|
||||
pub fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput<P> {
|
||||
self.press(from);
|
||||
self.move_to(from + NUDGE);
|
||||
self.move_to(to)
|
||||
}
|
||||
|
||||
/// A whole drag; the output is the release frame's.
|
||||
pub fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput<P> {
|
||||
self.hold(from, to);
|
||||
self.release(to)
|
||||
}
|
||||
|
||||
/// Press and release `key` in one frame.
|
||||
pub fn key(&mut self, key: Key) -> DockOutput<P> {
|
||||
let modifiers = self.modifiers;
|
||||
self.step(vec![
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
])
|
||||
}
|
||||
|
||||
/// Give or take the window's focus for this and the following frames.
|
||||
pub fn focus(&mut self, focused: bool) -> DockOutput<P> {
|
||||
self.focused = focused;
|
||||
self.step(vec![Event::WindowFocused(focused)])
|
||||
}
|
||||
|
||||
/// The pointer leaves the window.
|
||||
pub fn leave(&mut self) -> DockOutput<P> {
|
||||
self.step(vec![Event::PointerGone])
|
||||
}
|
||||
|
||||
/// Every gesture source of the last frame.
|
||||
pub fn hits(&self) -> Vec<(Hit<P>, Rect)> {
|
||||
DockArea::<P>::hit_rects(&self.ctx, self.id)
|
||||
}
|
||||
|
||||
/// The rect of one gesture source in the last frame.
|
||||
pub fn hit(&self, hit: &Hit<P>) -> Option<Rect> {
|
||||
self.hits().into_iter().find(|(h, _)| h == hit).map(|(_, rect)| rect)
|
||||
}
|
||||
|
||||
/// The shapes of the last frame.
|
||||
pub fn shapes(&self) -> &[ClippedShape] {
|
||||
&self.shapes
|
||||
}
|
||||
|
||||
/// Whether the last frame filled `rect` (an insertion line or a split preview).
|
||||
pub fn filled(&self, rect: Rect) -> bool {
|
||||
self.shapes.iter().any(|clipped| match &clipped.shape {
|
||||
egui::Shape::Rect(shape) => shape.rect == rect && shape.fill != egui::Color32::TRANSPARENT,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the last frame outlined `rect` without filling it.
|
||||
pub fn outlined(&self, rect: Rect) -> bool {
|
||||
self.shapes.iter().any(|clipped| match &clipped.shape {
|
||||
egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == egui::Color32::TRANSPARENT && shape.stroke.width > 0.0,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn button(&self, pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: self.modifiers }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user