diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 4c8985a9..f5b6bb6b 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -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 { + 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) -> 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 { diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index da75068b..e0b26d60 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -7,7 +7,7 @@ //! let mut params = params.clone(); //! params["command"] = json!(id); //! let command: DockCommand = 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(zone: Option, before: &Option>, default: ZoneName) -> Result, 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

, + right_to_left: bool, +} + +impl Zoned<'_, P> { + fn placement(&self, anchor: &P, zone: Option, before: &Option>, default: ZoneName) -> Result, 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 DockCommand

{ /// 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

) -> Result>, 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

, right_to_left: bool) -> Result>, 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

| Ok(vec![action]); match self { @@ -329,7 +343,7 @@ impl DockCommand

{ 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 DockCommand

{ 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 DockCommand

{ 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 DockCommand

{ /// 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

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + pub fn apply(&self, workspace: &mut Workspace

, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { let show = match self { Self::Activate { panel } | Self::Dock { panel, anchor: None, side: None, .. } @@ -457,7 +471,7 @@ impl DockCommand

{ 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) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index b106a39d..c84051bf 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -40,14 +40,14 @@ fn parse(params: Value) -> Result, serde_json::Error> { /// The actions `params` makes on a fresh workspace. fn actions(params: Value) -> Vec> { 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

, Vec

) { 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")]); +} diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 7049543f..ea88d380 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -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

{ Float { rect: Option<[f32; 4]> }, } +impl Placement

{ + /// 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

, anchor: &P, zone: Zone, right_to_left: bool) -> Result { + 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 Layout

{ *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 } => { diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index b924429f..b49c60a5 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -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)); diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 2f7cfb58..008994c8 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -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. diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index f9801054..0577a628 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -89,6 +89,8 @@ struct Driver { modifiers: Modifiers, focused: bool, transparent: bool, + /// Applied to the style every frame. + tweak: fn(&mut DockStyle), shapes: Vec, 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(); diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index cbcef4ef..62a2473b 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -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 ); diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 623c38e1..7e8a60ee 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -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), diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 3d2da9ba..75391f6e 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -158,7 +158,7 @@ impl

Default for Layout

{ } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] pub enum Placement

{ @@ -168,7 +168,13 @@ pub enum Placement

{ /// `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, + }, /// 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

{ }, } -/// 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) -> bool { +pub(super) fn valid_height(height: Option) -> bool { height.is_none_or(|v| v.is_finite() && (0.0..=MAX_EXTENT).contains(&v)) } @@ -504,7 +520,7 @@ impl Layout

{ } /// 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 Layout

{ let first = |g: Option<&Group

>| 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 Layout

{ Ok(()) } - pub fn apply(&mut self, action: Action

) -> Result<(), DockError> { + /// [`Self::apply_with_permissions`] with default permissions. + pub fn apply(&mut self, action: Action

) -> Result, 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

, 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

, permissions: impl Fn(&P) -> Permissions) -> Result, 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 Layout

{ } 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 Layout

{ } _ => {} } + 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>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + /// Returns every panel the actions closed, in order. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result, 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

) -> Result, DockError> { Ok(match action { Action::Close { panel } => vec![panel.clone()], @@ -990,6 +1009,16 @@ impl Layout

{ } } +/// Whether the anchor's permissions take this placement: tabs need `accepts_tabs`, side +/// splits `accepts_splits`. +fn placement_allowed

(placement: &Placement

, anchor: Permissions) -> bool { + match placement { + Placement::Tab { .. } | Placement::Split(Zone::Center) => anchor.accepts_tabs, + Placement::Split(_) => anchor.accepts_splits, + _ => true, + } +} + fn unit_of

(action: &Action

) -> Unit { match action { Action::MoveColumn { .. } => Unit::Column, diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 1309aa71..5a6843eb 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -62,9 +62,9 @@ fn panels_of(groups: &[Group]) -> Vec> { #[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 }, diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index ae6646ee..32d7240a 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -346,7 +346,8 @@ impl 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 }; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index becbd1dc..1ac0b65f 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -109,7 +109,7 @@ impl Renderer<'_, P> { fn title_bar(&mut self, ui: &mut Ui, window: &Floating

, 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 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 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 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 diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index d39235bc..0296daf6 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -213,7 +213,7 @@ impl 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 { diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 4ba0c0b8..2d1bc6f9 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -33,7 +33,8 @@ impl 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 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 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 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 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 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 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 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 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 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 { - 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 Renderer<'_, P> { let targets: Vec

= 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 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)); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 613bf689..37414bc7 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -127,7 +127,11 @@ impl 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 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 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()); } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c0b5e004..66c0ad8c 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -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

{ pub actions: Vec>, pub error: Option, + /// 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

, /// Whole-floating-group gesture identity; host may display its own group drop preview. pub group_drag: Option

, diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index cd370e5a..3f946153 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -12,6 +12,9 @@ pub struct DropTarget

{ 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(target: &DropTarget

, 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(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 DockTarget

{ pub(super) fn to(&self) -> Target

{ 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 }, } } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 618ca268..ac070249 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -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 { .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())); diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 9ce80b53..f7612993 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -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)); +} diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index ddf2ecee..aa7f2e3b 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -416,7 +416,7 @@ mod v1_fixtures { // Float the column by command, then drag its window back to the start edge. let float: DockCommand = 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"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 0e8b3ba6..96ea7ae3 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -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 = 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.