docking: fixes from DesignCraft trial step 2

- Permissions::accepts_splits; PROTECTED takes no side splits, so a
  group dropped on a protected canvas floats.
- A press of any pointer button in a panel body, stripless panels
  included, reports the panel in DockOutput::focused.
- Right to left, tab strips, their header and drag bar, insertion slots
  (DropTarget::right_to_left), accordion headers and chrome::TitleBar
  (ChromeStyle::right_to_left) mirror. TitleBar truncates galley titles
  like tab labels.
- DockStyle::close_menu_items and close_button hide the built-in close
  controls without changing Permissions::closable.
- Menu labels say what moves: Move Panel to Group (move_panel_to_label)
  and Move Group to (move_group_to_label).
- The column bar's button has its own accessibility labels
  (collapse_column_label, expand_column_label).
- Placement::for_zone maps zones onto what the anchor's container takes;
  DockCommand uses it and takes right_to_left.
- Layout::apply, apply_with_permissions and apply_all return the closed
  panels.
- Placement::Group takes an optional preferred height.
This commit is contained in:
aiongg committed 2026-10-19 12:24:38 +00:00
1 parent 9c909dba7f
commit 8a0df8c12f
22 files changed
+587 -152

No files matched your search

+36 -6
View File
@@ -3,7 +3,11 @@
//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes
//! a layout: the caller turns the responses into its own commands.
use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2};
use std::sync::Arc;
use egui::{
Color32, Context, FontId, Galley, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2,
};
use super::{DockStyle, PanelLimits, resize::AnchoredResize};
@@ -24,6 +28,8 @@ pub struct ChromeStyle {
pub weak_text: Color32,
pub accent: Color32,
pub font: FontId,
/// Mirror the title bar: buttons at the left, the title at the right.
pub right_to_left: bool,
}
impl ChromeStyle {
@@ -40,6 +46,7 @@ impl ChromeStyle {
weak_text: style.inactive_text,
accent: style.accent,
font: style.font.clone(),
right_to_left: style.right_to_left,
}
}
}
@@ -248,8 +255,21 @@ pub fn clamp(rect: Rect, bounds: Rect) -> Rect {
Rect::from_min_size(min, size)
}
/// `text` on one line no wider than `width`, cut at the end with '…'. egui does not wrap a
/// host's pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job.
pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> Arc<Galley> {
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), width, font);
if galley.size().x <= width + 0.5 {
return galley;
}
let mut job = (*galley.job).clone();
job.wrap = egui::text::TextWrapping::truncate_at_width(width);
ui.painter().layout_job(job)
}
/// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt
/// in from the right edge. The bar senses clicks and drags; double-clicks are the caller's.
/// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses
/// clicks and drags; double-clicks are the caller's.
pub struct TitleBar<'a> {
id: Id,
move_label: &'a str,
@@ -274,7 +294,7 @@ impl<'a> TitleBar<'a> {
Self { id, move_label, title: None, menu: None, close: None, movable: true }
}
/// Text at the left instead of the grip lines.
/// Text at the leading edge instead of the grip lines, cut with '…' to fit.
pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
self.title = Some(title.into());
self
@@ -302,17 +322,27 @@ impl<'a> TitleBar<'a> {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, style.title_background);
let side = rect.height();
// Laid out left to right, then mirrored in a right-to-left layout.
let mirror = |r: Rect| {
if style.right_to_left {
Rect::from_x_y_ranges((rect.left() + rect.right() - r.right())..=(rect.left() + rect.right() - r.left()), r.y_range())
} else {
r
}
};
let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)));
let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0);
let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom())));
let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left());
let bar_rect = Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom()));
let bar_rect = mirror(Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom())));
let (close_rect, menu_rect) = (close_rect.map(mirror), menu_rect.map(mirror));
let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag());
let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar };
bar.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.move_label));
if let Some(title) = self.title {
let galley = title.into_galley(ui, Some(egui::TextWrapMode::Truncate), (bar_rect.width() - 12.0).max(0.0), style.font.clone());
painter.galley(pos2(rect.left() + 6.0, rect.center().y - galley.size().y * 0.5), galley, style.text);
let galley = truncated(ui, title, (bar_rect.width() - 12.0).max(0.0), style.font.clone());
let x = if style.right_to_left { bar_rect.right() - 6.0 - galley.size().x } else { bar_rect.left() + 6.0 };
painter.galley(pos2(x, rect.center().y - galley.size().y * 0.5), galley, style.text);
} else {
let grip = Stroke::new(0.6, style.weak_text);
for k in 0..2 {
+37 -23
View File
@@ -7,7 +7,7 @@
//! let mut params = params.clone();
//! params["command"] = json!(id);
//! let command: DockCommand<PanelId> = serde_json::from_value(params)?;
//! command.apply(&mut app.workspace, |p| app.permissions(p))?;
//! command.apply(&mut app.workspace, style.right_to_left, |p| app.permissions(p))?;
//! ```
use std::hash::Hash;
@@ -40,7 +40,8 @@ const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0];
/// | `ui.dock` | `{action}`, or `{operation, …}`, see [`DockOperation`] |
///
/// `zone` is `tab` (the default for `move` and `dock`), `center`, `left`, `right`, `top`,
/// `bottom`, `above` or `below` (a new group in a dock column or floating stack). `before`
/// `bottom`, `above` or `below` (a new group in a dock column or floating stack). The side
/// zones map onto the anchor's container with [`Placement::for_zone`]. `before`
/// names the tab to insert before; `null` appends. `group` moves the panel's whole group,
/// `column` its dock column. `side` is `Start` or `End`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -232,21 +233,29 @@ fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) ->
T::deserialize(deserializer)
}
/// `zone` and `before` as a placement. `default` is the zone when none is given.
fn placement<P: Clone>(zone: Option<ZoneName>, before: &Option<Option<P>>, default: ZoneName) -> Result<Placement<P>, DockError> {
let before = before.clone();
Ok(match (zone.unwrap_or(default), before) {
(ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before },
(ZoneName::Tab, None) => Placement::Tab { before: None },
(ZoneName::Center, None) => Placement::Split(Zone::Center),
(_, Some(_)) => return Err(DockError::InvalidPlacement),
(ZoneName::Left, None) => Placement::Split(Zone::Left),
(ZoneName::Right, None) => Placement::Split(Zone::Right),
(ZoneName::Top, None) => Placement::Split(Zone::Top),
(ZoneName::Bottom, None) => Placement::Split(Zone::Bottom),
(ZoneName::Above, None) => Placement::Group { below: false },
(ZoneName::Below, None) => Placement::Group { below: true },
})
/// Where a command puts what it moves relative to `anchor`: `zone` and `before`, with side
/// zones mapped by [`Placement::for_zone`]. `default` is the zone when none is given.
struct Zoned<'a, P> {
layout: &'a Layout<P>,
right_to_left: bool,
}
impl<P: Clone + Eq + Hash> Zoned<'_, P> {
fn placement(&self, anchor: &P, zone: Option<ZoneName>, before: &Option<Option<P>>, default: ZoneName) -> Result<Placement<P>, DockError> {
let side = |zone| Placement::for_zone(self.layout, anchor, zone, self.right_to_left);
Ok(match (zone.unwrap_or(default), before.clone()) {
(ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before },
(ZoneName::Tab, None) => Placement::Tab { before: None },
(ZoneName::Center, None) => Placement::Split(Zone::Center),
(_, Some(_)) => return Err(DockError::InvalidPlacement),
(ZoneName::Left, None) => side(Zone::Left)?,
(ZoneName::Right, None) => side(Zone::Right)?,
(ZoneName::Top, None) => side(Zone::Top)?,
(ZoneName::Bottom, None) => side(Zone::Bottom)?,
(ZoneName::Above, None) => Placement::Group { below: false, height: None },
(ZoneName::Below, None) => Placement::Group { below: true, height: None },
})
}
}
/// How much of the layout a command moves.
@@ -319,8 +328,13 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// belong to the application: those return [`DockError::NotAllowed`]; use [`Self::apply`].
/// A panel or anchor that is not shown returns [`DockError::MissingPanel`] or
/// [`DockError::MissingTarget`].
pub fn to_actions(&self, workspace: &Workspace<P>) -> Result<Vec<Action<P>>, DockError> {
///
/// `left`, `right`, `top` and `bottom` zones follow [`Placement::for_zone`]: beside a docked
/// anchor they make a new column or group, and `right_to_left` is the
/// [`super::DockStyle::right_to_left`] the layout is drawn with.
pub fn to_actions(&self, workspace: &Workspace<P>, right_to_left: bool) -> Result<Vec<Action<P>>, DockError> {
let layout = &workspace.layout;
let zoned = Zoned { layout, right_to_left };
let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) };
let one = |action: Action<P>| Ok(vec![action]);
match self {
@@ -329,7 +343,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
shown(panel)?;
let placement = match given {
Some(placement) => placement.clone(),
None => placement(*zone, before, ZoneName::Tab)?,
None => zoned.placement(anchor, *zone, before, ZoneName::Tab)?,
};
one(Unit::of(*group, false).to(layout, panel, Target::At { anchor: anchor.clone(), placement })?)
}
@@ -358,7 +372,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
shown(panel)?;
let unit = Unit::of(*group, *column);
if let Some(anchor) = anchor {
let placement = placement(*zone, before, ZoneName::Tab)?;
let placement = zoned.placement(anchor, *zone, before, ZoneName::Tab)?;
return one(unit.to(layout, panel, Target::At { anchor: anchor.clone(), placement })?);
}
if let Some(side) = side {
@@ -425,7 +439,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }),
DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }),
DockOperation::Move { panel, target, zone, before } => {
let placement = placement(*zone, before, ZoneName::Center)?;
let placement = zoned.placement(target, *zone, before, ZoneName::Center)?;
one(Action::Move { panel: panel.clone(), anchor: target.clone(), placement })
}
DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }),
@@ -442,7 +456,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// the application can release their state. Showing a hidden panel restores its last
/// location; Lock sets [`Workspace::locked`]. Application-owned commands
/// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing.
pub fn apply(&self, workspace: &mut Workspace<P>, permissions: impl Fn(&P) -> Permissions) -> Result<Vec<P>, DockError> {
pub fn apply(&self, workspace: &mut Workspace<P>, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result<Vec<P>, DockError> {
let show = match self {
Self::Activate { panel }
| Self::Dock { panel, anchor: None, side: None, .. }
@@ -457,7 +471,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
workspace.locked = locked.unwrap_or(!workspace.locked);
return Ok(Vec::new());
}
let actions = self.to_actions(workspace)?;
let actions = self.to_actions(workspace, right_to_left)?;
workspace.apply_all(actions, permissions)
}
}
+38 -8
View File
@@ -40,14 +40,14 @@ fn parse(params: Value) -> Result<DockCommand<P>, serde_json::Error> {
/// The actions `params` makes on a fresh workspace.
fn actions(params: Value) -> Vec<Action<P>> {
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
command.to_actions(&workspace()).unwrap_or_else(|e| panic!("{params}: {e}"))
command.to_actions(&workspace(), false).unwrap_or_else(|e| panic!("{params}: {e}"))
}
/// Apply `params` to a fresh workspace; return it and the panels closed.
fn applied(params: Value) -> (Workspace<P>, Vec<P>) {
let mut workspace = workspace();
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
let closed = command.apply(&mut workspace, permissions).unwrap_or_else(|e| panic!("{params}: {e}"));
let closed = command.apply(&mut workspace, false, permissions).unwrap_or_else(|e| panic!("{params}: {e}"));
(workspace, closed)
}
@@ -56,7 +56,7 @@ fn rejected(params: Value, error: DockError) {
let mut workspace = workspace();
let before = workspace.clone();
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
assert_eq!(command.apply(&mut workspace, permissions), Err(error), "{params}");
assert_eq!(command.apply(&mut workspace, false, permissions), Err(error), "{params}");
assert_eq!(workspace, before, "{params}");
}
@@ -90,12 +90,15 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
assert_eq!(actions(with(json!({"before": "channels"}))), mv(tab(Some("channels"))));
assert_eq!(actions(with(json!({"zone": "center", "before": null}))), mv(tab(None)));
assert_eq!(actions(with(json!({"zone": "center"}))), mv(Placement::Split(Zone::Center)));
assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Split(Zone::Left)));
assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true }));
assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Column { after: false }));
assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true, height: None }));
assert_eq!(actions(with(json!({"placement": {"Column": {"after": true}}}))), mv(Placement::Column { after: true }));
assert_eq!(
actions(with(json!({"group": true, "zone": "above"}))),
vec![Action::MoveGroup { panel: s("history"), to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false } } }]
vec![Action::MoveGroup {
panel: s("history"),
to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false, height: None } }
}]
);
// float: a docked panel, a lone floating panel, a group and a column.
@@ -219,7 +222,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center)));
assert_eq!(
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))),
to_layers(Placement::Split(Zone::Left))
to_layers(Placement::Column { after: false })
);
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None)));
assert_eq!(
@@ -278,6 +281,33 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
let mut locked = workspace();
locked.locked = true;
let before = locked.clone();
assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, permissions).is_err());
assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, false, permissions).is_err());
assert_eq!(locked, before);
}
#[test]
fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() {
let command =
|zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap();
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl) {
Ok(actions) => match actions.as_slice() {
[Action::Move { placement, .. }] => Ok(placement.clone()),
other => panic!("{other:?}"),
},
Err(error) => Err(error),
};
// A start dock is on the left edge left to right, on the right edge right to left.
assert_eq!(placed("right", "layers", false), Ok(Placement::Column { after: true }));
assert_eq!(placed("right", "layers", true), Ok(Placement::Column { after: false }));
assert_eq!(placed("left", "layers", true), Ok(Placement::Column { after: true }));
assert_eq!(placed("top", "layers", false), Ok(Placement::Group { below: false, height: None }));
assert_eq!(placed("bottom", "color", false), Ok(Placement::Group { below: true, height: None }));
assert_eq!(placed("left", "color", false), Err(DockError::InvalidPlacement));
assert_eq!(placed("left", "canvas", true), Ok(Placement::Split(Zone::Left)));
let (workspace, _) = applied(json!({"command": "window.panel.move", "panel": "history", "anchor": "layers", "zone": "right"}));
let Some(Node::Split { first, .. }) = &workspace.layout.root else { panic!() };
let Node::Dock { columns, .. } = &**first else { panic!() };
assert_eq!(columns.len(), 2);
assert_eq!(columns[1].groups[0].panels, vec![s("history")]);
}
+42 -2
View File
@@ -6,7 +6,7 @@
use std::hash::Hash;
use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width};
use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_height, valid_rect, valid_width};
use crate::layout::{SplitAxis, SplitSize};
/// Width of a new column when nothing better is known.
@@ -84,6 +84,39 @@ pub enum Target<P> {
Float { rect: Option<[f32; 4]> },
}
impl<P: Clone + Eq + Hash> Placement<P> {
/// The placement for a drop zone on `anchor`'s group, in the vocabulary its container
/// accepts. `zone` is physical: `Left` is the left half on screen in either direction.
///
/// | Anchor in | `Center` | `Top`, `Bottom` | `Left`, `Right` |
/// |---|---|---|---|
/// | the split tree | [`Placement::Split`] | `Split` | `Split` |
/// | a dock column | `Split(Center)` (join as tabs) | [`Placement::Group`] above or below | [`Placement::Column`] on that side |
/// | a floating window | `Split(Center)` | `Group` above or below | [`DockError::InvalidPlacement`] |
///
/// A dock's `Column { after }` counts from its window edge, which depends on the dock's
/// side and `right_to_left` (see [`Side`]); this picks `after` so the new column lands on
/// the zone's side of the anchor's column.
pub fn for_zone(layout: &Layout<P>, anchor: &P, zone: Zone, right_to_left: bool) -> Result<Self, DockError> {
let place = layout.place(anchor).ok_or(DockError::MissingTarget)?;
Ok(match (place, zone) {
(Place::Node(_), zone) | (_, zone @ Zone::Center) => Placement::Split(zone),
(Place::Dock { .. } | Place::Floating { .. }, Zone::Top | Zone::Bottom) => Placement::Group { below: zone == Zone::Bottom, height: None },
(Place::Floating { .. }, _) => return Err(DockError::InvalidPlacement),
(Place::Dock { path, .. }, _) => {
let side = match layout.root.as_ref().and_then(|root| node_at(root, &path)) {
Some(Node::Dock { side, .. }) => *side,
_ => return Err(DockError::MissingTarget),
};
// The physical edge of the dock: Start is left in a left-to-right layout.
let left_edge = (side == Side::Start) != right_to_left;
// `after` is away from the window edge: rightwards for a left dock.
Placement::Column { after: (zone == Zone::Right) == left_edge }
}
})
}
}
/// What the Window menu shows for a panel. The model does not track the z-order of floating
/// windows, so `Front` means "showing": the active tab of an expanded group in an expanded column.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -501,7 +534,14 @@ impl<P: Clone + Eq + Hash> Layout<P> {
*node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) };
Ok(())
}
Placement::Group { below } => {
Placement::Group { below, height } => {
if !valid_height(height) {
return Err(DockError::InvalidGeometry);
}
let mut moving = moving;
if height.is_some() {
moving.groups.iter_mut().for_each(|group| group.height = height);
}
let (groups, index) = match &place {
Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group),
Place::Floating { window, group } => {
+2 -2
View File
@@ -211,7 +211,7 @@ fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() {
d.hold(bar(&d, 5), seam);
assert!(d.painted(hline(307.0, 0.0..=240.0)), "a horizontal line across the column at the seam");
let out = d.step(vec![button(seam, false)]);
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true } })));
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } })));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3]]]);
assert!(d.layout.floating.is_empty());
}
@@ -220,7 +220,7 @@ fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() {
fn a_tab_dropped_on_a_seam_or_column_edge_moves_only_that_panel() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]]));
let out = d.drag(d.tab(3).center(), pos2(120.0, 309.0));
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true } }]);
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true, height: None } }]);
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![4]]]);
d.settle();
d.drag(d.tab(4).center(), pos2(246.0, 200.0));
+11 -4
View File
@@ -204,7 +204,8 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro
// Released over another window's lower band: its groups stack below that window's.
let out = d.drag(title, pos2(660.0, 480.0));
assert!(
out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true } } }),
out.actions
.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true, height: None } } }),
"{:?}",
out.actions
);
@@ -237,7 +238,8 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() {
let seam = pos2(120.0, d.tab(4).unwrap().top());
let out = d.drag(title, seam);
assert!(
out.actions.contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true } } }),
out.actions
.contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } } }),
"{:?}",
out.actions
);
@@ -262,7 +264,8 @@ fn floating_stack_seams_and_bands_insert_groups() {
let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center();
let out = d.drag(bar, pos2(660.0, 360.0));
assert!(
out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false } } }),
out.actions
.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } } }),
"{:?}",
out.actions
);
@@ -274,7 +277,11 @@ fn floating_stack_seams_and_bands_insert_groups() {
let tab = d.tab(8).unwrap().center();
let seam = pos2(420.0, 237.0);
let out = d.drag(tab, seam);
assert!(out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), "{:?}", out.actions);
assert!(
out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true, height: None } }),
"{:?}",
out.actions
);
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]);
// The divider between two open groups resizes both.
+16 -9
View File
@@ -89,6 +89,8 @@ struct Driver {
modifiers: Modifiers,
focused: bool,
transparent: bool,
/// Applied to the style every frame.
tweak: fn(&mut DockStyle),
shapes: Vec<egui::epaint::ClippedShape>,
time: f64,
}
@@ -107,6 +109,7 @@ impl Driver {
modifiers: Modifiers::NONE,
focused: true,
transparent: false,
tweak: |_| {},
shapes: Vec::new(),
time: 0.0,
};
@@ -126,13 +129,14 @@ impl Driver {
time: Some(self.time),
..Default::default()
};
let (layout, content, area, transparent) = (&self.layout, &mut self.content, self.area, self.transparent);
let (layout, content, area, transparent, tweak) = (&self.layout, &mut self.content, self.area, self.transparent, self.tweak);
let mut full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
if transparent {
style.background = Color32::TRANSPARENT;
style.tab_background = Color32::TRANSPARENT;
}
tweak(&mut style);
ui.scope_builder(UiBuilder::new().max_rect(area), |ui| {
output = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content);
});
@@ -274,21 +278,24 @@ fn top_border_drags_the_group_and_the_menu_button_only_clicks() {
}
#[test]
fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_centre_floats() {
fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() {
let canvas = || TestContent { protected: vec![1], ..Default::default() };
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0));
let mut d = Driver::with(frames(), canvas(), area);
let mut d = Driver::with(frames(), TestContent::default(), area);
let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions);
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
assert_eq!(**first, tabs(&[2, 3], 1));
assert_eq!(**second, tabs(&[1], 0));
// The canvas centre takes nothing: no join, no preview; the group floats there.
let mut d = Driver::with(frames(), canvas(), area);
let out = d.drag(d.rect("drag-bar", 2).center(), pos2(150.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
// A protected canvas takes nothing, at its centre or its edge: no join, no split, no
// preview; the group floats there.
for at in [pos2(150.0, 300.0), pos2(20.0, 300.0)] {
let mut d = Driver::with(frames(), canvas(), area);
let out = d.drag(d.rect("drag-bar", 2).center(), at);
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
}
}
#[test]
@@ -508,7 +515,7 @@ fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() {
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Group with"] {
for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Move Group to"] {
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
}
h.get_by_label("Close Tab Group").click();
+2 -1
View File
@@ -254,7 +254,8 @@ fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() {
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
.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true, height: None } } }),
"{:?}",
out.actions
);
+9 -5
View File
@@ -19,7 +19,7 @@ impl DockStyle {
/// |---|---|---|
/// | `dock.float` | `float_label` | Float panel |
/// | `dock.close` | `close_label` | Close panel |
/// | `dock.move` | `move_label` | Move to group |
/// | `dock.movePanelTo` | `move_panel_to_label` | Move Panel to Group |
/// | `dock.panels` | `panels_label` | Panels |
/// | `dock.resize` | `resize_label` | Resize panels |
/// | `dock.resizeWindow` | `resize_window_label` | Resize panel window |
@@ -28,10 +28,12 @@ impl DockStyle {
/// | `dock.minimize` | `minimize_label` | Minimize |
/// | `dock.expand` | `expand_label` | Expand |
/// | `dock.floatGroup` | `float_group_label` | Float Tab Group |
/// | `dock.groupWith` | `group_with_label` | Group with |
/// | `dock.moveGroupTo` | `move_group_to_label` | Move Group to |
/// | `dock.moveColumn` | `move_column_label` | Move panel column |
/// | `dock.collapseToIcons` | `collapse_to_icons_label` | Collapse to Icons |
/// | `dock.expandPanels` | `expand_panels_label` | Expand Panels |
/// | `dock.collapseColumn` | `collapse_column_label` | Collapse column to icons |
/// | `dock.expandColumn` | `expand_column_label` | Expand column |
/// | `dock.moveWindow` | `move_window_label` | Move panel window |
/// | `dock.closeWindow` | `close_window_label` | Close panel window |
/// | `dock.undock` | `undock_label` | Undock Panel |
@@ -52,11 +54,11 @@ impl DockStyle {
self
}
fn labels_mut(&mut self) -> [(&'static str, &mut String); 25] {
fn labels_mut(&mut self) -> [(&'static str, &mut String); 27] {
[
("dock.float", &mut self.float_label),
("dock.close", &mut self.close_label),
("dock.move", &mut self.move_label),
("dock.movePanelTo", &mut self.move_panel_to_label),
("dock.panels", &mut self.panels_label),
("dock.resize", &mut self.resize_label),
("dock.resizeWindow", &mut self.resize_window_label),
@@ -65,10 +67,12 @@ impl DockStyle {
("dock.minimize", &mut self.minimize_label),
("dock.expand", &mut self.expand_label),
("dock.floatGroup", &mut self.float_group_label),
("dock.groupWith", &mut self.group_with_label),
("dock.moveGroupTo", &mut self.move_group_to_label),
("dock.moveColumn", &mut self.move_column_label),
("dock.collapseToIcons", &mut self.collapse_to_icons_label),
("dock.expandPanels", &mut self.expand_panels_label),
("dock.collapseColumn", &mut self.collapse_column_label),
("dock.expandColumn", &mut self.expand_column_label),
("dock.moveWindow", &mut self.move_window_label),
("dock.closeWindow", &mut self.close_window_label),
("dock.undock", &mut self.undock_label),
+55 -26
View File
@@ -158,7 +158,7 @@ impl<P> Default for Layout<P> {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Placement<P> {
@@ -168,7 +168,13 @@ pub enum Placement<P> {
/// `Tabs` and `Stack` nodes only.
Split(Zone),
/// A new group above or below the anchor's group, in a dock column or a floating stack.
Group { below: bool },
/// `height` is the inserted group's preferred height ([`super::Group::height`]); `None`
/// keeps a moved group's own height, or shares the free height for a new group.
Group {
below: bool,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
height: Option<f32>,
},
/// A new column before (towards the window edge) or after the anchor's column.
Column { after: bool },
/// A new frame along one edge of the docked layout, a quarter of its width or height: the
@@ -306,29 +312,39 @@ pub enum Action<P> {
},
}
/// Rendering and command adapters can protect a document canvas while leaving panels movable.
/// What may happen to a panel. [`Layout::apply_with_permissions`] enforces these for every
/// action, whoever issues it. They also decide which gestures and menu items the renderer
/// offers; [`super::DockStyle::close_menu_items`] and [`super::DockStyle::close_button`] hide
/// the built-in close controls of a panel that stays closable by command.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Permissions {
pub movable: bool,
pub floatable: bool,
pub closable: bool,
/// Other panels may join this panel's group as tabs.
pub accepts_tabs: bool,
/// Other panels may split this panel's frame (`Placement::Split` with a side zone).
/// Outer-edge frames ([`Placement::Edge`]) are not splits of the anchor.
pub accepts_splits: bool,
}
impl Default for Permissions {
fn default() -> Self {
Self { movable: true, floatable: true, closable: true, accepts_tabs: true }
Self { movable: true, floatable: true, closable: true, accepts_tabs: true, accepts_splits: true }
}
}
impl Permissions {
pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false };
/// A document canvas: it stays docked and open, and takes neither tabs nor side splits.
/// A group dropped on it floats there. An application that opens panels beside its canvas
/// uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`.
pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false, accepts_splits: false };
/// Lock Workspace: nothing moves, floats or closes; tab drops keep their policy.
/// Lock Workspace: nothing moves, floats or closes; tab and split drops keep their policy.
/// [`super::Workspace::apply`] applies this when the workspace is locked; an application
/// with a global lock preference applies it in its own permissions callback.
pub fn locked(self) -> Self {
Self { movable: false, floatable: false, closable: false, accepts_tabs: self.accepts_tabs }
Self { movable: false, floatable: false, closable: false, ..self }
}
}
@@ -380,7 +396,7 @@ fn valid_size(size: SplitSize) -> bool {
}
}
fn valid_height(height: Option<f32>) -> bool {
pub(super) fn valid_height(height: Option<f32>) -> bool {
height.is_none_or(|v| v.is_finite() && (0.0..=MAX_EXTENT).contains(&v))
}
@@ -504,7 +520,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
}
/// The panels `panel` can join as a tab: every other panel whose permissions accept tabs.
/// A "Move to group" menu with an empty answer has nothing to offer and stays hidden.
/// A "Move Panel to Group" menu with an empty answer has nothing to offer and stays hidden.
pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> {
self.panels().into_iter().filter(|target| *target != panel && permissions(target).accepts_tabs).collect()
}
@@ -540,10 +556,10 @@ impl<P: Clone + Eq + Hash> Layout<P> {
let first = |g: Option<&Group<P>>| g.and_then(|g| g.panels.first()).cloned();
if let Some(anchor) = first(groups.get(group + 1)) {
location.anchor = Some(anchor);
location.placement = Placement::Group { below: false };
location.placement = Placement::Group { below: false, height: None };
} else if let Some(anchor) = first(group.checked_sub(1).and_then(|i| groups.get(i))) {
location.anchor = Some(anchor);
location.placement = Placement::Group { below: true };
location.placement = Placement::Group { below: true, height: None };
} else if groups.len() == 1
&& let Place::Dock { path, column, .. } = &place
&& let Some(Node::Dock { columns, .. }) = self.root.as_ref().and_then(|root| node_at(root, path))
@@ -686,21 +702,23 @@ impl<P: Clone + Eq + Hash> Layout<P> {
Ok(())
}
pub fn apply(&mut self, action: Action<P>) -> Result<(), DockError> {
/// [`Self::apply_with_permissions`] with default permissions.
pub fn apply(&mut self, action: Action<P>) -> Result<Vec<P>, DockError> {
self.apply_with_permissions(action, |_| Permissions::default())
}
/// Check application policy before making an atomic change. The callback must be stable
/// throughout one call. Registry validation remains with the application, particularly Open.
pub fn apply_with_permissions(&mut self, action: Action<P>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> {
/// Check application policy before making an atomic change, and return the panels the
/// action closed (see [`Self::closed_by`]) so the application can release their state.
/// The callback must be stable throughout one call. Registry validation remains with the
/// application, particularly Open.
pub fn apply_with_permissions(&mut self, action: Action<P>, permissions: impl Fn(&P) -> Permissions) -> Result<Vec<P>, DockError> {
self.validate()?;
match &action {
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
if !permissions(panel).movable {
return Err(DockError::NotAllowed);
}
let tab = matches!(placement, Placement::Tab { .. } | Placement::Split(Zone::Center));
if tab && !permissions(anchor).accepts_tabs {
if !placement_allowed(placement, permissions(anchor)) {
return Err(DockError::NotAllowed);
}
}
@@ -715,9 +733,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
}
match to {
Target::Float { .. } if members.iter().any(|p| !permissions(p).floatable) => return Err(DockError::NotAllowed),
Target::At { anchor, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) }
if !members.contains(anchor) && !permissions(anchor).accepts_tabs =>
{
Target::At { anchor, placement } if !members.contains(anchor) && !placement_allowed(placement, permissions(anchor)) => {
return Err(DockError::NotAllowed);
}
_ => {}
@@ -735,26 +751,29 @@ impl<P: Clone + Eq + Hash> Layout<P> {
}
_ => {}
}
let closed = self.closed_by(&action)?;
let mut proposed = self.clone();
proposed.apply_inner(action)?;
proposed.validate()?;
*self = proposed;
Ok(())
Ok(closed)
}
/// Apply every action in order, or none: the first rejection leaves the layout unchanged.
pub fn apply_all(&mut self, actions: impl IntoIterator<Item = Action<P>>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> {
/// Returns every panel the actions closed, in order.
pub fn apply_all(&mut self, actions: impl IntoIterator<Item = Action<P>>, permissions: impl Fn(&P) -> Permissions) -> Result<Vec<P>, DockError> {
let mut proposed = self.clone();
let mut closed = Vec::new();
for action in actions {
proposed.apply_with_permissions(action, &permissions)?;
closed.extend(proposed.apply_with_permissions(action, &permissions)?);
}
*self = proposed;
Ok(())
Ok(closed)
}
/// The panels a close action (`Close`, `CloseGroup`, `CloseWindow`, `CloseOthers`) would
/// remove, in group order, so the application can release their state once the action is
/// applied. Empty for other actions.
/// remove, in group order. Empty for other actions. The apply methods return the same
/// list; this one answers before applying.
pub fn closed_by(&self, action: &Action<P>) -> Result<Vec<P>, DockError> {
Ok(match action {
Action::Close { panel } => vec![panel.clone()],
@@ -990,6 +1009,16 @@ impl<P: Clone + Eq + Hash> Layout<P> {
}
}
/// Whether the anchor's permissions take this placement: tabs need `accepts_tabs`, side
/// splits `accepts_splits`.
fn placement_allowed<P>(placement: &Placement<P>, anchor: Permissions) -> bool {
match placement {
Placement::Tab { .. } | Placement::Split(Zone::Center) => anchor.accepts_tabs,
Placement::Split(_) => anchor.accepts_splits,
_ => true,
}
}
fn unit_of<P>(action: &Action<P>) -> Unit {
match action {
Action::MoveColumn { .. } => Unit::Column,
+4 -4
View File
@@ -62,9 +62,9 @@ fn panels_of(groups: &[Group<u32>]) -> Vec<Vec<u32>> {
#[test]
fn group_moves_keep_order_active_tab_and_minimized_state() {
let targets = [
Target::At { anchor: 5, placement: Placement::Group { below: true } },
Target::At { anchor: 5, placement: Placement::Group { below: true, height: None } },
Target::At { anchor: 5, placement: Placement::Column { after: true } },
Target::At { anchor: 8, placement: Placement::Group { below: false } },
Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } },
Target::NewDock { side: Side::Start },
Target::Float { rect: Some([20.0, 20.0, 200.0, 300.0]) },
Target::Float { rect: None },
@@ -143,7 +143,7 @@ fn column_moves_and_floats_keep_every_group() {
#[test]
fn window_docks_as_stacked_groups_or_merges_as_tabs() {
let mut layout = docked();
layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true } } }).unwrap();
layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } }).unwrap();
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![2, 3], vec![4], vec![6, 7], vec![8]]);
assert!(layout.floating.is_empty());
@@ -287,7 +287,7 @@ fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resi
Action::Move { panel: 2, anchor: 5, placement: Placement::Tab { before: None } },
Action::MoveGroup { panel: 2, to: Target::Float { rect: None } },
Action::MoveColumn { panel: 5, to: Target::NewDock { side: Side::Start } },
Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true } } },
Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } },
Action::Float { panel: 4, rect: [0.0, 0.0, 100.0, 100.0] },
Action::MoveFloating { panel: 6, rect: [0.0, 0.0, 100.0, 100.0] },
Action::Close { panel: 4 },
+2 -1
View File
@@ -346,7 +346,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if usable(button) {
let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click());
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &label));
let button_label = if column.iconic { &self.style.expand_column_label } else { &self.style.collapse_column_label };
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), button_label));
// The arrows point where the column goes: towards the window edge when it collapses.
let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" };
let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text };
+16 -4
View File
@@ -109,7 +109,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
fn title_bar(&mut self, ui: &mut Ui, window: &Floating<P>, title: Rect, rect: Rect, style: &ChromeStyle) {
let Some(first) = window.panels().next() else { return };
let movable = window.panels().all(|p| self.permissions(p).movable);
let closable = window.panels().all(|p| self.permissions(p).closable);
let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable);
let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone());
let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable);
if closable {
@@ -256,7 +256,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let anchor = members.first()?.clone();
return Some(DropProposal {
anchor,
placement: Placement::Group { below: true },
placement: Placement::Group { below: true, height: None },
preview: Rect::NOTHING,
insertion: None,
noop: true,
@@ -266,7 +266,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
Some(panel) => (panel, true),
None => (anchor(b)?, false),
};
return Some(DropProposal { anchor, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion: Some(line), noop: false });
return Some(DropProposal {
anchor,
placement: Placement::Group { below, height: None },
preview: Rect::NOTHING,
insertion: Some(line),
noop: false,
});
}
let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?;
if strip.contains(pointer) || same(panels) {
@@ -281,7 +287,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
return None;
};
let insertion = Some(line(if below { rect.bottom() } else { rect.top() }));
Some(DropProposal { anchor: anchor(panels)?, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion, noop: false })
Some(DropProposal {
anchor: anchor(panels)?,
placement: Placement::Group { below, height: None },
preview: Rect::NOTHING,
insertion,
noop: false,
})
}
/// What a group drag carries: the whole window rect for the only group of a floating
+1 -1
View File
@@ -213,7 +213,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
if self.style.profile == Profile::Frame {
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
if owner.panels.len() > 1 && others && self.menu_action(ui, &self.style.close_others_label.clone()) {
if owner.panels.len() > 1 && others && self.style.close_menu_items && self.menu_action(ui, &self.style.close_others_label.clone()) {
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
}
if owner.kind == GroupKind::Frame {
+35 -27
View File
@@ -33,7 +33,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) {
let Some(selected) = panels.get(active) else { return };
let policy = self.permissions(selected);
let splits = kind == GroupKind::Frame;
let splits = kind == GroupKind::Frame && policy.accepts_splits;
let rtl = self.style.right_to_left;
if stripless(panels, &*self.content) {
self.contents(ui, selected, rect);
self.targets.push(DropTarget {
@@ -43,6 +44,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
tabs: vec![(selected.clone(), Rect::NOTHING)],
allow_splits: splits,
accepts_tabs: policy.accepts_tabs,
right_to_left: rtl,
});
return;
}
@@ -50,10 +52,19 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
ui.painter().rect_filled(strip, 0.0, self.style.tab_background);
let floating = matches!(kind, GroupKind::Floating { .. });
// Laid out left to right, then mirrored in a right-to-left layout: tabs from the right
// edge, the header (the host's ≡) at the left.
let mirror = |r: Rect| {
if rtl {
Rect::from_x_y_ranges((strip.left() + strip.right() - r.right())..=(strip.left() + strip.right() - r.left()), r.y_range())
} else {
r
}
};
let requested = self.content.group_header_width(panels, selected, floating);
let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 };
if header_width > 0.0 {
let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max);
let header = mirror(Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max));
let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header));
child.set_clip_rect(header.intersect(ui.clip_rect()));
self.content.group_header(&mut child, panels, selected, floating);
@@ -78,13 +89,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let mut shown = Vec::new();
for (index, width) in &fit.shown {
let Some(panel) = panels.get(*index) else { continue };
let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height()));
let tab = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())));
self.tab(ui, panel, tab, *index == active, rect, Some(&owner));
shown.push((panel.clone(), tab));
x += width;
}
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height()));
let overflow = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())));
let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click());
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label));
@@ -104,10 +115,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
});
x += fit.overflow_width;
}
let bar = Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom()));
let bar =
mirror(Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom())));
self.group_handle(ui, "drag-bar", bar, &owner, movable);
// Registered last, so the band wins over the tabs below it.
let border = Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom())));
let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom()))));
self.group_handle(ui, "drag-border", border, &owner, movable);
// Logical order, hidden tabs included without a hit area, for no-op detection.
let tabs =
@@ -119,6 +131,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
tabs,
allow_splits: splits,
accepts_tabs: policy.accepts_tabs,
right_to_left: rtl,
});
if !minimized {
self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max));
@@ -150,7 +163,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let active = response.has_focus() || response.hovered();
if movable {
// A grip mark at rest, centred in the bar's reserved width beside the header.
let grip = Rect::from_min_max(pos2((hit.right() - self.style.drag_bar_width).max(hit.left()), hit.top()), hit.max);
let grip = if self.style.right_to_left {
Rect::from_min_max(hit.min, pos2((hit.left() + self.style.drag_bar_width).min(hit.right()), hit.bottom()))
} else {
Rect::from_min_max(pos2((hit.right() - self.style.drag_bar_width).max(hit.left()), hit.top()), hit.max)
};
let stroke = Stroke::new(1.0, if active { self.style.text } else { self.style.inactive_text });
let half = (hit.height() * 0.2).min(4.0);
for k in -1..=1 {
@@ -192,10 +209,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
/// The group items of the menu tail: Close Tab Group, Minimize or Expand, Float Tab Group
/// and Group with. `panel` is the panel whose menu this is.
/// and Move Group to. `panel` is the panel whose menu this is.
fn group_menu(&mut self, ui: &mut Ui, owner: &TabGroup<'_, P>, panel: &P) {
let all = |check: &dyn Fn(crate::docking::Permissions) -> bool| owner.panels.iter().all(|p| check(self.permissions(p)));
let closable = all(&|p| p.closable);
let closable = self.style.close_menu_items && all(&|p| p.closable);
let movable = all(&|p| p.movable);
let floatable = movable && all(&|p| p.floatable);
if closable && self.menu_action(ui, &self.close_group_label()) {
@@ -220,7 +237,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
let targets = if movable { self.group_targets(owner.panels) } else { Vec::new() };
if !targets.is_empty() {
let logical = self.style.group_with_label.clone();
let logical = self.style.move_group_to_label.clone();
let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text);
let response = ui.menu_button(text, |ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
@@ -278,21 +295,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
response
}
/// A tab's label, cut at the end with '…' to fit `width`. egui does not wrap a host's
/// pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job.
/// A tab's label, cut at the end with '…' to fit `width`.
pub(super) fn tab_label(&mut self, ui: &Ui, panel: &P, label: &str, color: Color32, width: f32) -> Arc<Galley> {
let galley = self.content.label(ui, panel, label, &self.style.font, color).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
width,
self.style.font.clone(),
);
if galley.size().x <= width + 0.5 {
return galley;
}
let mut job = (*galley.job).clone();
job.wrap = egui::text::TextWrapping::truncate_at_width(width);
ui.painter().layout_job(job)
let text = self.content.label(ui, panel, label, &self.style.font, color);
crate::docking::chrome::truncated(ui, text, width, self.style.font.clone())
}
/// True while a tab's focus came from the pointer, which shows no focus outline. Tab or an
@@ -369,7 +375,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let targets: Vec<P> =
if policy.movable { self.layout.move_targets(panel, |p| self.permissions(p)).into_iter().cloned().collect() } else { Vec::new() };
if !targets.is_empty() {
let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text);
let text = self.content.action_label(ui, &self.style.move_panel_to_label, &self.style.font, self.style.text);
let response = ui.menu_button(text, |ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for target in &targets {
@@ -384,9 +390,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
});
});
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label));
response
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_panel_to_label));
}
if policy.closable {
if policy.closable && self.style.close_menu_items {
let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text);
let response = ui.button(text);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.close_label));
+9 -3
View File
@@ -127,7 +127,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom())));
let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom())));
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 collapse = if self.style.right_to_left {
Rect::from_min_max(title_rect.min, pos2((title_rect.left() + 18.0).min(title_rect.right()), title_rect.bottom()))
} else {
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(|| {
@@ -152,8 +156,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
rect: panel_rect,
tab_strip: Some(title_rect),
tabs: vec![(entry.panel.clone(), title_rect)],
allow_splits: true,
allow_splits: self.permissions(&entry.panel).accepts_splits,
accepts_tabs: self.permissions(&entry.panel).accepts_tabs,
right_to_left: self.style.right_to_left,
});
top = panel_rect.bottom();
if entry.open && remaining > 0.0 {
@@ -211,7 +216,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
self.content.body(&mut child, panel);
if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) {
// Any button: a canvas pans with the middle button and opens menus with the secondary.
if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) {
self.output.focused = Some(panel.clone());
}
}
+22 -4
View File
@@ -28,7 +28,8 @@ pub struct DockStyle {
/// Localize these action labels in the application adapter.
pub float_label: String,
pub close_label: String,
pub move_label: String,
/// Tab menu item that moves one panel into another group (submenu of target panels).
pub move_panel_to_label: String,
pub panels_label: String,
pub resize_label: String,
pub resize_window_label: String,
@@ -38,7 +39,8 @@ pub struct DockStyle {
pub minimize_label: String,
pub expand_label: String,
pub float_group_label: String,
pub group_with_label: String,
/// Group menu item that moves the whole group into another group (submenu of targets).
pub move_group_to_label: String,
/// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow,
/// before the bar narrows.
pub drag_bar_width: f32,
@@ -74,8 +76,12 @@ pub struct DockStyle {
pub right_to_left: bool,
/// [D] Accessibility label of a column bar.
pub move_column_label: String,
/// [D] Column menu and group menu items.
pub collapse_to_icons_label: String,
pub expand_panels_label: String,
/// [D] Accessibility labels of the column bar's »/« button, distinct from the menu items.
pub collapse_column_label: String,
pub expand_column_label: String,
pub move_window_label: String,
pub close_window_label: String,
/// Frame profile menu labels.
@@ -87,6 +93,12 @@ pub struct DockStyle {
pub restore_frame_label: String,
/// Shown where native windows are available (feature `native-windows`).
pub new_window_label: String,
/// Show the built-in Close items in tab and group menus (Close panel, Close Tab Group,
/// Close Other Panels in Group) for closable panels. A host that draws its own Close items
/// turns this off and keeps [`Permissions::closable`], so its close actions still apply.
pub close_menu_items: bool,
/// Show × on the title bar of a floating window whose panels are all closable.
pub close_button: bool,
}
/// Which arrangement a [`DockStyle`] renders and labels.
@@ -114,7 +126,7 @@ impl DockStyle {
font: FontId::proportional(12.0),
float_label: "Float panel".into(),
close_label: "Close panel".into(),
move_label: "Move to group".into(),
move_panel_to_label: "Move Panel to Group".into(),
panels_label: "Panels".into(),
resize_label: "Resize panels".into(),
resize_window_label: "Resize panel window".into(),
@@ -123,7 +135,7 @@ impl DockStyle {
minimize_label: "Minimize".into(),
expand_label: "Expand".into(),
float_group_label: "Float Tab Group".into(),
group_with_label: "Group with".into(),
move_group_to_label: "Move Group to".into(),
drag_bar_width: 40.0,
floating_background: visuals.window_fill,
floating_stroke: visuals.window_stroke,
@@ -142,6 +154,8 @@ impl DockStyle {
move_column_label: "Move panel column".into(),
collapse_to_icons_label: "Collapse to Icons".into(),
expand_panels_label: "Expand Panels".into(),
collapse_column_label: "Collapse column to icons".into(),
expand_column_label: "Expand column".into(),
move_window_label: "Move panel window".into(),
close_window_label: "Close panel window".into(),
undock_label: "Undock Panel".into(),
@@ -151,6 +165,8 @@ impl DockStyle {
maximize_label: "Maximize Frame".into(),
restore_frame_label: "Restore Frame Size".into(),
new_window_label: "Move Panel to New Window".into(),
close_menu_items: true,
close_button: true,
}
}
@@ -185,6 +201,8 @@ impl DockStyle {
pub struct DockOutput<P> {
pub actions: Vec<Action<P>>,
pub error: Option<DockError>,
/// The panel the user focused this frame: a clicked tab or menu entry, a drop, a raised
/// window, or a press of any pointer button in a panel body, stripless panels included.
pub focused: Option<P>,
/// Whole-floating-group gesture identity; host may display its own group drop preview.
pub group_drag: Option<P>,
+16 -5
View File
@@ -12,6 +12,9 @@ pub struct DropTarget<P> {
pub tabs: Vec<(P, Rect)>,
pub allow_splits: bool,
pub accepts_tabs: bool,
/// The strip runs right to left: the first tab is rightmost, and an insertion slot sits
/// at the right edge of the tab it precedes.
pub right_to_left: bool,
}
#[derive(Clone, Debug, PartialEq)]
@@ -70,11 +73,18 @@ pub fn tab_insertion_marker<P: Eq>(target: &DropTarget<P>, before: Option<&P>) -
return None;
}
let index = before.and_then(|before| target.tabs.iter().position(|(panel, _)| panel == before));
let rtl = target.right_to_left;
let leading = |rect: &Rect| if rtl { rect.right() } else { rect.left() };
let visible = || target.tabs.iter().filter(|(_, rect)| usable(*rect)).map(|(_, rect)| rect);
let x = index
.and_then(|index| target.tabs.get(index..))
.and_then(|tabs| tabs.iter().find(|(_, rect)| usable(*rect)).map(|(_, rect)| rect.left()))
.and_then(|tabs| tabs.iter().find(|(_, rect)| usable(*rect)).map(|(_, rect)| leading(rect)))
.unwrap_or_else(|| {
target.tabs.iter().filter(|(_, rect)| usable(*rect)).map(|(_, rect)| rect.right()).fold(strip.left(), f32::max).min(strip.right())
if rtl {
visible().map(|rect| rect.left()).fold(strip.right(), f32::min).max(strip.left())
} else {
visible().map(|rect| rect.right()).fold(strip.left(), f32::max).min(strip.right())
}
});
let width = strip.width().min(2.0);
let left = (x - width * 0.5).max(strip.left()).min((strip.right() - width).max(strip.left()));
@@ -98,7 +108,8 @@ pub fn resolve_drop<P: Clone + Eq>(panel: &P, pointer: Pos2, targets: &[DropTarg
if !target.accepts_tabs {
return None;
}
let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| pointer.x < r.center().x);
let ahead = |r: &Rect| if target.right_to_left { pointer.x > r.center().x } else { pointer.x < r.center().x };
let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| ahead(r));
let before_panel = before.map(|(p, _)| p.clone());
let insertion = tab_insertion_marker(target, before_panel.as_ref())?;
let old_order: Vec<_> = target.tabs.iter().map(|(p, _)| p).collect();
@@ -176,8 +187,8 @@ impl<P: Clone> DockTarget<P> {
pub(super) fn to(&self) -> Target<P> {
match self {
Self::ColumnEdge { anchor, after } => Target::At { anchor: anchor.clone(), placement: Placement::Column { after: *after } },
Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true } },
Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below } },
Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true, height: None } },
Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below, height: None } },
Self::NewDock { side } => Target::NewDock { side: *side },
}
}
+13 -5
View File
@@ -61,7 +61,7 @@ fn move_targets_skip_the_panel_itself_and_protected_anchors() {
let guarded = |p: &u32| if *p == 3 { Permissions::PROTECTED } else { Permissions::default() };
assert!(!layout.move_targets(&1, guarded).contains(&&3));
// A lone panel has nowhere to go — its "Move to group" menu hides
// A lone panel has nowhere to go — its "Move Panel to Group" menu hides
// (storytold/lightcraft#1768).
let single = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() };
assert!(single.move_targets(&1, all).is_empty());
@@ -135,13 +135,15 @@ fn protected_canvas_remains_present_and_cannot_be_grouped_with_panels() {
move_to(1, 3, Zone::Center),
move_to(3, 1, Zone::Center),
Action::OpenAt { panel: 1, anchor: 3, placement: Placement::Tab { before: None } },
Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) },
] {
let mut next = layout.clone();
assert_eq!(next.apply_with_permissions(action, permissions), Err(DockError::NotAllowed));
assert_eq!(next, layout);
}
let beside = |panel: &u32| if *panel == 1 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() };
let mut next = layout;
next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, permissions).unwrap();
next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, beside).unwrap();
assert!(next.contains(&1) && next.contains(&7));
}
@@ -229,6 +231,7 @@ fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget<u32> {
.collect(),
allow_splits: true,
accepts_tabs: true,
right_to_left: false,
}
}
@@ -266,7 +269,7 @@ fn long_action_sequence_never_loses_or_duplicates_a_panel() {
2 => Placement::Split(Zone::Top),
3 => Placement::Split(Zone::Bottom),
4 => Placement::Split(Zone::Center),
5 => Placement::Group { below: seed & 1 == 0 },
5 => Placement::Group { below: seed & 1 == 0, height: None },
_ => Placement::Column { after: seed & 2 == 0 },
};
let to = match (seed >> 32) % 4 {
@@ -1244,7 +1247,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx));
for logical in ["Float panel", "Move to group", "Close panel"] {
for logical in ["Float panel", "Move Panel to Group", "Close panel"] {
assert!(
h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))),
"custom action must paint: {logical}"
@@ -1381,6 +1384,7 @@ fn returning_to_a_singleton_or_floating_source_body_is_an_explicit_noop() {
tabs: panels.iter().map(|id| (*id, Rect::NOTHING)).collect(),
allow_splits,
accepts_tabs: true,
right_to_left: false,
};
let proposal = resolve_drop(&1, rect.center(), &[target]).unwrap();
assert!(proposal.noop);
@@ -1441,6 +1445,7 @@ fn unchanged_tab_slots_are_explicit_noops_with_bounded_insertion_markers() {
],
allow_splits: true,
accepts_tabs: true,
right_to_left: false,
};
for (panel, positions) in [(1, [10.0, 50.0]), (2, [70.0, 110.0]), (3, [130.0, 210.0])] {
for x in positions {
@@ -1522,6 +1527,7 @@ fn overflow_noop_detection_uses_logical_order_including_hidden_tabs() {
],
allow_splits: true,
accepts_tabs: true,
right_to_left: false,
};
assert!(resolve_drop(&1, pos2(10.0, 12.0), std::slice::from_ref(&target)).unwrap().noop);
// Before the next visible tab crosses hidden tab 2 and really changes the order.
@@ -1542,6 +1548,7 @@ fn insertion_markers_stay_inside_clipped_and_singleton_strips() {
tabs: vec![(1, Rect::from_min_size(pos2(tab_left, 20.0), vec2(60.0, 30.0)))],
allow_splits: true,
accepts_tabs: true,
right_to_left: false,
};
for x in [rect.left(), rect.center().x, rect.right()] {
let proposal = resolve_drop(&1, pos2(x, 25.0), std::slice::from_ref(&target)).unwrap();
@@ -1557,7 +1564,8 @@ fn insertion_markers_stay_inside_clipped_and_singleton_strips() {
#[test]
fn insertion_marker_handles_subpixel_strip_crossing_zero_without_panicking() {
let rect = Rect::from_min_max(pos2(-0.001, 0.0), pos2(0.002, 30.0));
let target = DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true };
let target =
DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true, right_to_left: false };
let proposal = resolve_drop(&1, pos2(0.0, 12.0), &[target]).unwrap();
assert!(proposal.noop);
assert!(rect.contains_rect(proposal.insertion.unwrap()));
+193 -2
View File
@@ -1,5 +1,7 @@
//! Fixes from the DesignCraft trial: group float size, tab label elision, the drag ghost and
//! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups.
//! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups; then
//! split permissions, body focus, right-to-left strips, close controls, menu and button names,
//! zone mapping, closed panels from `apply` and group heights.
use super::*;
@@ -190,7 +192,7 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() {
});
full.textures_delta.clear();
let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect();
assert_eq!(values.len(), 25, "{debug}");
assert_eq!(values.len(), 27, "{debug}");
assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}");
let unique: std::collections::HashSet<_> = values.iter().collect();
assert_eq!(unique.len(), values.len());
@@ -199,3 +201,192 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() {
assert!(window.contains("swatches"));
assert!(!window.contains("layers"));
}
const AREA_600: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(600.0, 600.0) };
#[test]
fn a_protected_canvas_takes_no_side_split_and_a_group_dropped_on_its_edge_floats() {
let protected = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() };
let split = Action::Move { panel: 4, anchor: 0, placement: Placement::Split(Zone::Left) };
let mut layout = docked();
assert_eq!(layout.apply_with_permissions(split.clone(), protected), Err(DockError::NotAllowed));
let opt_in = |p: &u32| if *p == 0 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() };
assert!(layout.apply_with_permissions(split, opt_in).is_ok());
let mut d = Driver::with(docked(), TestContent { protected: vec![0], ..Default::default() }, AREA_600);
let bar = d.rect("drag-bar", 2);
let out = d.drag(bar.center(), pos2(300.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
}
#[test]
fn a_press_in_a_stripless_panel_reports_it_focused() {
let mut d = Driver::with(docked(), TestContent { protected: vec![0], ..Default::default() }, AREA_600);
for pressed in [PointerButton::Primary, PointerButton::Secondary, PointerButton::Middle] {
let at = pos2(420.0, 300.0);
let press = Event::PointerButton { pos: at, button: pressed, pressed: true, modifiers: Modifiers::NONE };
let out = d.step(vec![Event::PointerMoved(at), press]);
assert_eq!(out.focused, Some(0), "{pressed:?}");
d.step(vec![Event::PointerButton { pos: at, button: pressed, pressed: false, modifiers: Modifiers::NONE }]);
d.step(vec![key(egui::Key::Escape)]);
}
}
#[test]
fn right_to_left_mirrors_the_tab_strip_and_its_insertion_slots() {
let mut d = Driver::with(frames(), TestContent { header: 30.0, ..Default::default() }, AREA_600);
d.tweak = |style| style.right_to_left = true;
d.step(vec![]);
d.step(vec![]);
let (first, second, bar) = (d.tab(2), d.tab(3), d.rect("drag-bar", 2));
assert!((first.right() - 600.0).abs() < 1.0, "the first tab starts at the right edge: {first:?}");
assert!(second.right() <= first.left() + 0.5, "{first:?} {second:?}");
assert!(bar.right() <= second.left() + 0.5 && bar.left() >= 304.0 + 30.0 - 0.5, "the bar sits between the tabs and the header: {bar:?}");
// Just right of the first tab's centre is before it in a right-to-left strip.
let out = d.drag(d.tab(1).center(), pos2(first.center().x + 6.0, first.center().y));
assert!(out.actions.contains(&Action::Move { panel: 1, anchor: 3, placement: Placement::Tab { before: Some(2) } }), "{:?}", out.actions);
let strip = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
let target = DropTarget {
anchor: 1,
rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 80.0)),
tab_strip: Some(strip),
tabs: vec![(1, Rect::from_min_max(pos2(60.0, 0.0), pos2(100.0, 20.0))), (2, Rect::from_min_max(pos2(20.0, 0.0), pos2(60.0, 20.0)))],
allow_splits: true,
accepts_tabs: true,
right_to_left: true,
};
let drop = |x: f32| resolve_drop(&9, pos2(x, 10.0), std::slice::from_ref(&target)).unwrap();
assert_eq!((drop(90.0).placement, drop(90.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(1) }, 99.0));
assert_eq!((drop(70.0).placement, drop(70.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(2) }, 60.0));
assert_eq!((drop(10.0).placement, drop(10.0).insertion.unwrap().center().x), (Placement::Tab { before: None }, 20.0));
}
#[test]
fn a_right_to_left_title_bar_puts_its_buttons_left_and_cuts_a_galley_title() {
let ctx = Context::default();
let mut shapes = Vec::new();
let mut rects = (Rect::NOTHING, Rect::NOTHING, Rect::NOTHING);
let bar = Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 18.0));
for _ in 0..2 {
let mut full = ctx.run_ui(RawInput::default(), |ui| {
let mut style = DockStyle::from_ui(ui);
style.right_to_left = true;
let chrome = chrome::ChromeStyle::from_dock(&style);
let title = egui::WidgetText::Galley(ui.painter().layout_no_wrap("Paragraph Styles and More".into(), style.font.clone(), style.text));
let shown = chrome::TitleBar::new(Id::new("title"), "Move").title(title).menu("Menu").close("Close").show(ui, bar, &chrome);
rects = (shown.bar.rect, shown.menu.unwrap().rect, shown.close.unwrap().rect);
});
full.textures_delta.clear();
shapes = full.shapes;
}
let (grip, menu, close) = rects;
assert!(close.center().x < menu.center().x && menu.right() <= grip.left() + 0.5, "{close:?} {menu:?} {grip:?}");
assert!((grip.right() - bar.right()).abs() < 0.5);
let title = flat(&shapes)
.into_iter()
.find_map(|(_, shape)| match shape {
egui::Shape::Text(text) if text.galley.text().starts_with("Para") => Some(text),
_ => None,
})
.unwrap();
assert_eq!(last_char(&title.galley), Some('…'));
assert!(title.pos.x + title.galley.size().x <= grip.right() + 0.5 && title.pos.x >= grip.left());
}
#[test]
fn hidden_close_controls_leave_closing_to_the_host() {
let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] };
let close = Id::new(AREA).with(("move-group", Some(&5u32))).with("close");
let mut d = Driver::new(layout);
assert!(d.ctx.read_response(close).is_some());
d.tweak = |style| {
style.close_button = false;
style.close_menu_items = false;
};
d.step(vec![]);
d.step(vec![]);
assert!(d.ctx.read_response(close).is_none());
let at = d.tab(1).center();
let secondary = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE };
d.step(vec![Event::PointerMoved(at), secondary(true)]);
d.step(vec![secondary(false)]);
d.step(vec![]);
let painted = |d: &Driver, label: &str| texts(d).iter().any(|(_, t)| t.galley.text() == label);
assert!(painted(&d, "Float panel"), "the tab menu is open");
assert!(!painted(&d, "Close panel") && !painted(&d, "Close Tab Group"));
// The panels stay closable: the host's own Close applies and reports what it closed.
assert_eq!(d.layout.apply_with_permissions(Action::CloseWindow { panel: 5 }, |p| d.content.permissions(p)), Ok(vec![5, 6]));
}
#[test]
fn apply_all_returns_every_panel_its_actions_closed() {
let mut layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] };
let closed = layout
.apply_all([Action::Activate { panel: 2 }, Action::CloseOthers { panel: 2 }, Action::CloseWindow { panel: 6 }], |_| Permissions::default());
assert_eq!(closed, Ok(vec![1, 3, 5, 6]));
assert_eq!(layout.apply(Action::Close { panel: 2 }), Ok(vec![2]));
}
#[test]
fn the_column_button_and_the_column_menu_item_have_distinct_names() {
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, content: &mut TestContent| {
DockArea::new(Id::new(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content);
},
TestContent { protected: vec![0], ..Default::default() },
);
h.run_steps(2);
assert_eq!(h.query_all_by_label("Collapse column to icons").count(), 1);
assert_eq!(h.query_all_by_label("Collapse to Icons").count(), 0);
let at = h.ctx.read_response(Id::new(AREA).with(("column-bar", Some(&2u32)))).unwrap().rect.center();
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.query_all_by_label("Collapse to Icons").count(), 1);
assert_eq!(h.query_all_by_label("Collapse column to icons").count(), 1);
}
#[test]
fn for_zone_maps_side_zones_onto_columns_groups_and_splits() {
let layout = Layout { root: docked().root, floating: vec![Floating::single(vec![5], 0, [300.0, 80.0, 200.0, 200.0])] };
let zone = |anchor, zone, rtl| Placement::for_zone(&layout, &anchor, zone, rtl);
assert_eq!(zone(2, Zone::Right, false), Ok(Placement::Column { after: true }));
assert_eq!(zone(2, Zone::Right, true), Ok(Placement::Column { after: false }));
assert_eq!(zone(4, Zone::Bottom, false), Ok(Placement::Group { below: true, height: None }));
assert_eq!(zone(2, Zone::Center, false), Ok(Placement::Split(Zone::Center)));
assert_eq!(zone(5, Zone::Top, false), Ok(Placement::Group { below: false, height: None }));
assert_eq!(zone(5, Zone::Left, false), Err(DockError::InvalidPlacement));
assert_eq!(zone(0, Zone::Left, true), Ok(Placement::Split(Zone::Left)));
assert_eq!(zone(9, Zone::Left, false), Err(DockError::MissingTarget));
// An end dock mirrors a start dock.
let mut end = docked();
let Some(Node::Split { first, second, size, .. }) = &mut end.root else { panic!() };
std::mem::swap(first, second);
*size = SplitSize::FixedSecond(240.0);
let Node::Dock { side, .. } = &mut **second else { panic!() };
*side = Side::End;
assert_eq!(Placement::for_zone(&end, &2, Zone::Left, false), Ok(Placement::Column { after: true }));
assert_eq!(Placement::for_zone(&end, &2, Zone::Left, true), Ok(Placement::Column { after: false }));
}
#[test]
fn a_group_placement_gives_the_inserted_group_its_height() {
let mut layout = docked();
let below = |height| Placement::Group { below: true, height };
layout.apply(Action::OpenAt { panel: 7, anchor: 4, placement: below(Some(180.0)) }).unwrap();
layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 7, placement: below(Some(90.0)) } }).unwrap();
layout.apply(Action::OpenAt { panel: 8, anchor: 4, placement: below(None) }).unwrap();
let Some(Node::Split { first, .. }) = &layout.root else { panic!() };
let Node::Dock { columns, .. } = &**first else { panic!() };
let heights: Vec<_> = columns[0].groups.iter().map(|g| (g.panels.clone(), g.height)).collect();
assert_eq!(heights, vec![(vec![4], None), (vec![8], None), (vec![7], Some(180.0)), (vec![2, 3], Some(90.0))]);
assert_eq!(layout.apply(Action::OpenAt { panel: 9, anchor: 4, placement: below(Some(f32::NAN)) }), Err(DockError::InvalidGeometry));
}
+1 -1
View File
@@ -416,7 +416,7 @@ mod v1_fixtures {
// Float the column by command, then drag its window back to the start edge.
let float: DockCommand<String> =
serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap();
let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone())).unwrap();
let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false).unwrap();
d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}"));
d.settle();
assert!(start_columns(&d.layout).is_empty(), "{at}: floated");
+27 -9
View File
@@ -18,7 +18,10 @@ Office apps that draw their own document with a few task panes beside it use
Sides are logical: `Side::Start` is the left edge, or the right edge when
`DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its
locale.
locale. Right to left, tab strips also mirror: the first tab is rightmost, the group header and
drag bar are at the left, and drop insertion slots follow. Accordion headers and floating title
bars (`chrome::TitleBar`, through `ChromeStyle::right_to_left`) mirror too. Frame splits keep
their sides.
## Model
@@ -57,17 +60,25 @@ layout.apply(Action::Move {
- `Move`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a
group, a dock column or a floating window, named by any panel in it.
- A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`.
- A `Placement` is a tab position, a split zone, a new group above or below, a new column, or a
new frame on an outer edge.
- A `Placement` is a tab position, a split zone, a new group above or below (with an optional
preferred `height`), a new column, or a new frame on an outer edge.
- Containers take different placements: a dock column takes groups and columns, not splits.
`Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or
centre zone onto what the anchor's container takes: a split in the frame tree, a new column on
that side or a new group above or below in a dock, a group above or below in a floating window.
- Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move
a floating window to its own OS window (`SetNative`, feature `native-windows`).
Every action checks the whole result before it commits. A bad target, a duplicate id, a stale
split path or invalid geometry returns a `DockError` and leaves the layout unchanged. The source
panel is never lost.
panel is never lost. `apply`, `apply_with_permissions` and `apply_all` return the panels the
actions closed, so the app can release their state; `closed_by` lists them before applying.
`Permissions` limit what may happen to a panel. `Permissions::PROTECTED` keeps a canvas docked
and open and takes no tabs, but allows splits beside it. `apply_with_permissions` applies them.
`Permissions` limit what may happen to a panel: `movable`, `floatable`, `closable`,
`accepts_tabs` and `accepts_splits`. `apply_with_permissions` enforces them for every action,
whoever issues it. `Permissions::PROTECTED` keeps a canvas docked and open and takes neither tabs
nor side splits; a group dropped on it floats there. An app that opens panels beside its canvas
uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`.
## Workspace
@@ -98,7 +109,8 @@ survives.
`DockOutput` also has:
- `focused`: the focused panel.
- `focused`: the panel the user focused this frame: a clicked tab, a drop, a raised window, or a
press of any pointer button in a panel body, bare and protected panels included.
- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and
`DockEvent::ToggleAutoCollapse` (Dock).
- `error`: a rejected gesture.
@@ -113,6 +125,10 @@ Builder options on `DockArea`:
| `raise_panel(panel)` | Raise and focus a panel's floating window this frame. |
| `blocked_rects(rects)` | App overlays that docking gestures must ignore. |
`DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items
and the floating window ×. They hide controls only: a panel that stays `closable` still closes
by the app's own Close items and commands.
`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a
panel body. `begin_drag` hands a drag that started in the app's own chrome to the area.
`docking::chrome` has the floating frame and title bar for apps that draw their own windows.
@@ -141,7 +157,7 @@ let command: DockCommand<PanelId> = serde_json::from_value(params)?;
if command.is_app_owned() {
return app.workspace_command(command);
}
let closed = command.apply(&mut app.workspace, |panel| app.permissions(panel))?;
let closed = command.apply(&mut app.workspace, style.right_to_left, |panel| app.permissions(panel))?;
```
| Id | Params (`?` is optional) |
@@ -160,7 +176,9 @@ let closed = command.apply(&mut app.workspace, |panel| app.permissions(panel))?;
| `ui.dock` | `{action}`, or `{operation, …}` |
- `zone` is `tab`, `center`, `left`, `right`, `top`, `bottom`, `above` or `below`. `before` names
the tab to insert before; `null` appends.
the tab to insert before; `null` appends. `left`, `right`, `top` and `bottom` follow
`Placement::for_zone`, so beside a docked panel they make a new column or group. `apply` and
`to_actions` take the layout's `right_to_left` for that.
- `group` moves the panel's whole group; `column` moves its dock column, or its whole window when
it floats. `side` is `Start` or `End`.
- Without a value, `minimized`, `iconic` and `locked` toggle.